Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8bf14ff0 | |||
| 4212200dca | |||
| 5920923d92 | |||
| 00ca7229df | |||
| 679dedf132 | |||
| 12610faba0 | |||
| 73b44202ba | |||
| eed55cbb0f | |||
| 14c97bee62 | |||
| 8d3fe70e2c | |||
| da88c98c7a | |||
| b7ad01f9da | |||
| 868e61979e | |||
| 9bdd928469 | |||
| 75e489e39c | |||
| 41ea3aeb83 | |||
| eb39dcfa61 | |||
| 93ee194ba0 | |||
| b6d9113579 | |||
| b2a29ab68d | |||
| 117a0ee9dd | |||
| 61d59ba0e4 | |||
| 0a584f6722 | |||
| 219bca264e | |||
| c7e6a809ed | |||
| c5d6116f99 | |||
| 8672ed1e6c | |||
| 46468890d5 | |||
| cd503378e2 | |||
| 86762eab6d | |||
| a7fd193770 | |||
| 521c6d63fc | |||
| 66d04d838d | |||
| ad30c0a943 | |||
| 44afa8f53b | |||
| 481b937c33 | |||
| 790efb585b | |||
| 3acf95a824 | |||
| 7d69c82c2b | |||
| ae2872e08f | |||
| 303f4502dd | |||
| 815c9dcbcd | |||
| ef53ac1c93 | |||
| 2a3e8b1422 | |||
| 563f5a702c | |||
| c7f3ca9be3 | |||
| dbaadb8037 | |||
| ce001fe202 | |||
| a329eca419 | |||
| 528de938c5 | |||
| 4f791d491e | |||
| dd79891874 | |||
| a13288e759 | |||
| a16c8ec2d9 | |||
| 0e3712116f | |||
| ab45f95790 | |||
| d31bc63b6a | |||
| bc8f4b0c25 | |||
| 55ee99c839 | |||
| 3477fa733f | |||
| c6f45ac22e | |||
| b4c93ac79c | |||
| c09f167760 | |||
| b79200e950 | |||
| a800a630a8 | |||
| e4d5bb0364 | |||
| 36757a8c9a | |||
| cfbf3ea142 | |||
| f3cb1eb86b | |||
| 2b57025f3c | |||
| 2a14e28589 | |||
| 39bac7d2be | |||
| af8e120c9f | |||
| 0d38856b3e | |||
| 0a73aab825 | |||
| 0344ce2b98 | |||
| eb38ee343c | |||
| eacdf2bf4b | |||
| 57cd05ce73 | |||
| 76b3730245 | |||
| 18278a3357 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in Scarf
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
What you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots.
|
||||
|
||||
**Environment**
|
||||
- macOS version:
|
||||
- Xcode version:
|
||||
- Hermes version:
|
||||
- Scarf version/commit:
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for Scarf
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem?**
|
||||
A clear description of the problem.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
What you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
Other solutions you've thought about.
|
||||
|
||||
**Additional context**
|
||||
Any other context, screenshots, or mockups.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Xcode
|
||||
build/
|
||||
.gh-pages-worktree/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata/
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
Packages/
|
||||
Package.pins
|
||||
Package.resolved
|
||||
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
# IDE
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
scarf/standards/backups/
|
||||
|
||||
# Scarf project dashboards (user-specific)
|
||||
.scarf/
|
||||
|
||||
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
|
||||
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
||||
releases/v*/*.zip
|
||||
releases/v*/appcast-entry.xml
|
||||
@@ -0,0 +1,64 @@
|
||||
# Scarf — macOS GUI for the Hermes AI Agent
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup — auto-discovers files)
|
||||
scarf/ Main app target source
|
||||
Core/Services/ HermesDataService, HermesFileService, HermesLogService, ACPClient, HermesFileWatcher
|
||||
Core/Models/ Plain structs: HermesSession, HermesMessage, HermesConfig, etc.
|
||||
Features/ MVVM-F feature modules (Dashboard, Sessions, Activity, Chat, Memory, Skills, Cron, Logs, Settings)
|
||||
Navigation/ AppCoordinator, SidebarView
|
||||
docs/ PRD, Architecture, Discovery notes
|
||||
standards/ Copied development standards (read-only reference)
|
||||
```
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
- **MVVM-F**: Features never import sibling features. Cross-feature goes through services.
|
||||
- **AppCoordinator**: Single `@Observable` coordinator for all navigation state, injected via `.environment()`.
|
||||
- **No external dependencies**: System SQLite3, Foundation JSON, AttributedString markdown.
|
||||
- **Read-only DB access**: Never write to `~/.hermes/state.db`. Only write to memory files and cron jobs.
|
||||
- **Sandbox disabled**: App reads `~/.hermes/` directly.
|
||||
- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await.
|
||||
|
||||
## Key Paths
|
||||
|
||||
- Hermes home: `~/.hermes/`
|
||||
- SQLite DB: `~/.hermes/state.db` (WAL mode, read-only)
|
||||
- Config: `~/.hermes/config.yaml`
|
||||
- Memory: `~/.hermes/memories/MEMORY.md`, `~/.hermes/memories/USER.md`
|
||||
- Sessions: `~/.hermes/sessions/session_*.json`
|
||||
- Cron: `~/.hermes/cron/jobs.json`
|
||||
- Logs: `~/.hermes/logs/errors.log`, `~/.hermes/logs/gateway.log`
|
||||
- ACP: `hermes acp` subprocess (stdio JSON-RPC)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
|
||||
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
|
||||
```
|
||||
|
||||
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
|
||||
|
||||
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
|
||||
|
||||
**Canonical prompts (any of these trigger the flow):**
|
||||
- "Release v1.6.2" — full release
|
||||
- "Release v1.6.2 as draft" — draft mode
|
||||
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
|
||||
|
||||
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
|
||||
|
||||
## Hermes Version
|
||||
|
||||
Targets Hermes v0.9.0 (v2026.4.13). 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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Contributing to Scarf
|
||||
|
||||
Thanks for your interest in contributing to Scarf.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork and clone the repo
|
||||
2. Open `scarf/scarf.xcodeproj` in Xcode 26.3+
|
||||
3. Build and run (requires macOS 26.2+ and Hermes installed at `~/.hermes/`)
|
||||
|
||||
## Architecture
|
||||
|
||||
Scarf uses the MVVM-Feature pattern. Each feature is a self-contained module under `Features/`:
|
||||
|
||||
```
|
||||
Features/FeatureName/
|
||||
Views/ SwiftUI views
|
||||
ViewModels/ @Observable view models
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Features never import sibling features directly
|
||||
- Cross-feature navigation goes through `AppCoordinator`
|
||||
- Services in `Core/Services/` are shared across features
|
||||
- Models in `Core/Models/` are plain structs
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep it simple. Minimal dependencies, no over-engineering.
|
||||
- No commented-out code, TODOs, or deferred functionality in PRs.
|
||||
- All code must build with zero warnings.
|
||||
- Follow existing patterns — look at how similar features are built before adding new ones.
|
||||
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
||||
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Open an issue with:
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- macOS version and Hermes version
|
||||
- Steps to reproduce
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Open an issue first to discuss the change
|
||||
- One feature or fix per PR
|
||||
- Include a clear description of what changed and why
|
||||
- Ensure the project builds with `xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Alan Wizemann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,382 @@
|
||||
<p align="center">
|
||||
<img src="icon.png" width="128" height="128" alt="Scarf app icon">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Scarf</h1>
|
||||
|
||||
<p align="center">
|
||||
A native macOS companion app for the <a href="https://github.com/hermes-ai/hermes-agent">Hermes AI agent</a>.<br>
|
||||
Full visibility into what Hermes is doing, when, and what it creates.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
||||
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br><br>
|
||||
<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.0
|
||||
|
||||
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
|
||||
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
|
||||
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
|
||||
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
|
||||
|
||||
See the full [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0).
|
||||
|
||||
### Previously, in 1.6
|
||||
|
||||
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
|
||||
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
|
||||
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
|
||||
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
|
||||
- **Configure sidebar** — Personalities, Quick Commands, Plugins, Webhooks, Profiles
|
||||
|
||||
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
|
||||
|
||||
## Multi-server, one window per server
|
||||
|
||||
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…** → **Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
|
||||
|
||||
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
|
||||
|
||||
## Features
|
||||
|
||||
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
|
||||
|
||||
### Monitor
|
||||
|
||||
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||
|
||||
### Interact
|
||||
|
||||
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
|
||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
|
||||
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
|
||||
|
||||
### Configure *(new in 1.6)*
|
||||
|
||||
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
|
||||
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
|
||||
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
|
||||
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
|
||||
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
|
||||
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
|
||||
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
|
||||
|
||||
### Manage
|
||||
|
||||
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
|
||||
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
|
||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
|
||||
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
|
||||
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
|
||||
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
|
||||
|
||||
### Project Dashboards
|
||||
|
||||
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
|
||||
|
||||
### System
|
||||
|
||||
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14.6+ (Sonoma)
|
||||
- Xcode 16.0+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.9.0+ recommended for full feature support)
|
||||
- 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.
|
||||
|
||||
### Compatibility
|
||||
|
||||
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
|
||||
|
||||
| Hermes Version | Status |
|
||||
|----------------|--------|
|
||||
| v0.6.0 (2026-03-30) | Verified |
|
||||
| v0.7.0 (2026-04-03) | Verified |
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13) | Verified |
|
||||
| v0.10.0 (2026-04-18) | Verified (recommended for full 2.0 feature support) |
|
||||
|
||||
Scarf 2.0 targets Hermes v0.10.0 for the ACP session/fork/list/resume capabilities used by remote chat. Earlier Hermes versions remain supported for monitoring, sessions, and file-based features; ACP-specific behavior may gracefully degrade 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.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-built Binary (no Xcode required)
|
||||
|
||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
|
||||
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
|
||||
|
||||
1. Unzip and drag **Scarf.app** to Applications
|
||||
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
|
||||
|
||||
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/awizemann/scarf.git
|
||||
cd scarf/scarf
|
||||
open scarf.xcodeproj
|
||||
```
|
||||
|
||||
Or from the command line:
|
||||
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Scarf follows the **MVVM-Feature** pattern with zero external dependencies beyond SwiftTerm:
|
||||
|
||||
```
|
||||
scarf/
|
||||
Core/
|
||||
Models/ Plain data structs (HermesSession, HermesMessage, HermesConfig, etc.)
|
||||
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
|
||||
Features/ Self-contained feature modules
|
||||
Dashboard/ System overview and stats
|
||||
Insights/ Usage analytics and activity patterns
|
||||
Sessions/ Conversation browser with rename, delete, export
|
||||
Activity/ Tool execution feed with inspector
|
||||
Projects/ Agent-generated project dashboards with widget rendering
|
||||
Chat/ Rich ACP chat and embedded terminal with voice controls
|
||||
Memory/ Memory viewer and editor
|
||||
Skills/ Skill browser by category
|
||||
Tools/ Toolset management per platform
|
||||
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
|
||||
Gateway/ Messaging gateway control and pairing
|
||||
Cron/ Scheduled job viewer
|
||||
Logs/ Real-time log viewer
|
||||
Settings/ Structured config editor
|
||||
Navigation/ AppCoordinator + SidebarView
|
||||
```
|
||||
|
||||
### Data Sources
|
||||
|
||||
Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
|
||||
| Source | Format | Access |
|
||||
|--------|--------|--------|
|
||||
| `state.db` | SQLite (WAL mode) | Read-only |
|
||||
| `config.yaml` | YAML | Read-only |
|
||||
| `memories/*.md` | Markdown | Read/Write |
|
||||
| `cron/jobs.json` | JSON | Read-only |
|
||||
| `logs/*.log` | Text | Read-only |
|
||||
| `gateway_state.json` | JSON | Read-only |
|
||||
| `skills/` | Directory tree | Read-only |
|
||||
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
|
||||
| `hermes chat` | Terminal subprocess | Interactive |
|
||||
| `hermes tools` | CLI commands | Enable/Disable |
|
||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
|
||||
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
|
||||
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||
|
||||
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
|
||||
|
||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||
|
||||
## How It Works
|
||||
|
||||
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
|
||||
|
||||
The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
|
||||
|
||||
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
|
||||
|
||||
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
||||
|
||||
## Project Dashboards
|
||||
|
||||
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
||||
|
||||
### What You Can Build
|
||||
|
||||
- **Development dashboards** — test coverage, build status, open issues, sprint progress
|
||||
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
|
||||
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
||||
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
|
||||
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||
|
||||
### Quick Start
|
||||
|
||||
**1. Create the dashboard file**
|
||||
|
||||
Create `.scarf/dashboard.json` in any project folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "My Project",
|
||||
"description": "Project status at a glance",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"columns": 3,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Test Coverage",
|
||||
"value": "87%",
|
||||
"icon": "checkmark.shield",
|
||||
"color": "green",
|
||||
"subtitle": "+2.1% this week"
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"title": "Sprint Progress",
|
||||
"value": 0.73,
|
||||
"label": "73% complete",
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Tasks",
|
||||
"items": [
|
||||
{ "text": "Write unit tests", "status": "done" },
|
||||
{ "text": "Update API docs", "status": "active" },
|
||||
{ "text": "Deploy to prod", "status": "pending" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**2. Register your project**
|
||||
|
||||
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3. View in Scarf**
|
||||
|
||||
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
|
||||
|
||||
### Widget Types
|
||||
|
||||
| Type | Description | Key Fields |
|
||||
|------|-------------|------------|
|
||||
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
|
||||
| `progress` | Progress bar with label | `value` (0.0–1.0), `label`, `color` |
|
||||
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
|
||||
| `table` | Data table with headers | `columns`, `rows` |
|
||||
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
||||
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
||||
|
||||
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
|
||||
|
||||
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "webview",
|
||||
"title": "Project Report",
|
||||
"url": "http://localhost:8000/dashboard",
|
||||
"height": 500
|
||||
}
|
||||
```
|
||||
|
||||
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
|
||||
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
|
||||
|
||||
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
||||
|
||||
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
|
||||
|
||||
### Agent-Generated Dashboards
|
||||
|
||||
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
||||
|
||||
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
|
||||
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||
|
||||
### Dashboard Schema Reference
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Required — dashboard title",
|
||||
"description": "Optional — subtitle text",
|
||||
"updatedAt": "Optional — ISO 8601 timestamp",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Section Name",
|
||||
"columns": 3,
|
||||
"widgets": [{ "type": "...", "title": "..." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||
|
||||
## Releases
|
||||
|
||||
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
|
||||
|
||||
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
|
||||
|
||||
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
|
||||
|
||||
Signing prerequisites (one-time):
|
||||
|
||||
- `Developer ID Application` certificate in the login Keychain
|
||||
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
|
||||
1. Fork the repo
|
||||
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add my feature'`)
|
||||
4. Push to the branch (`git push origin feature/my-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## Support
|
||||
|
||||
If you find Scarf useful, consider buying me a coffee.
|
||||
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="40"></a>
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -1,106 +0,0 @@
|
||||
// Scarf landing page — minimal client behavior.
|
||||
// No dependencies. Runs after defer-parse.
|
||||
|
||||
(function () {
|
||||
const root = document.documentElement;
|
||||
const STORAGE_KEY = 'scarf-theme';
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
root.setAttribute('data-theme', theme);
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
}
|
||||
applyImageTheme();
|
||||
}
|
||||
|
||||
// Resolve the *effective* theme — explicit data-theme wins, otherwise
|
||||
// fall back to the OS preference.
|
||||
function resolveTheme() {
|
||||
const explicit = root.getAttribute('data-theme');
|
||||
if (explicit === 'light' || explicit === 'dark') return explicit;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// Swap every <img data-dark-src="..."> between its light and dark variants.
|
||||
// Also rewrites the parent <picture>'s <source srcset> so the picture
|
||||
// algorithm doesn't override us on resize/layout passes.
|
||||
function applyImageTheme() {
|
||||
const theme = resolveTheme();
|
||||
document.querySelectorAll('img[data-dark-src]').forEach((img) => {
|
||||
if (!img.dataset.lightSrc) {
|
||||
img.dataset.lightSrc = img.getAttribute('src');
|
||||
}
|
||||
const target = theme === 'dark' ? img.dataset.darkSrc : img.dataset.lightSrc;
|
||||
if (img.getAttribute('src') !== target) img.setAttribute('src', target);
|
||||
const picture = img.parentElement;
|
||||
if (picture && picture.tagName === 'PICTURE') {
|
||||
picture.querySelectorAll('source').forEach((s) => {
|
||||
if (s.getAttribute('srcset') !== target) s.setAttribute('srcset', target);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hydrate stored preference (if any) — runs after DOMContentLoaded since
|
||||
// the <script> is deferred. There's a brief moment of media-query default
|
||||
// before hydrate; that's acceptable here (no FOUC because the media query
|
||||
// already gets the right colors and the first images render at light by
|
||||
// default — JS swaps within a frame on dark-mode systems).
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') applyTheme(stored);
|
||||
else applyImageTheme(); // initial pass even if no stored preference
|
||||
} catch (_) {
|
||||
applyImageTheme();
|
||||
}
|
||||
|
||||
const toggle = document.querySelector('[data-theme-toggle]');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', () => {
|
||||
const current = root.getAttribute('data-theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
let next;
|
||||
if (current === 'light') next = 'dark';
|
||||
else if (current === 'dark') next = null;
|
||||
else next = prefersDark ? 'light' : 'dark';
|
||||
|
||||
applyTheme(next);
|
||||
try {
|
||||
if (next) localStorage.setItem(STORAGE_KEY, next);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Re-apply on system preference change so users who haven't set an
|
||||
// explicit override still get matching screenshots.
|
||||
if (window.matchMedia) {
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => {
|
||||
if (!root.hasAttribute('data-theme')) applyImageTheme();
|
||||
};
|
||||
if (mql.addEventListener) mql.addEventListener('change', onChange);
|
||||
else if (mql.addListener) mql.addListener(onChange);
|
||||
}
|
||||
|
||||
// Auto-collapse sticky header on scroll-down, restore on scroll-up.
|
||||
const header = document.querySelector('.site-header');
|
||||
if (header) {
|
||||
let lastY = window.scrollY;
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (ticking) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const y = window.scrollY;
|
||||
if (y > 80 && y > lastY) header.style.transform = 'translateY(-100%)';
|
||||
else header.style.transform = '';
|
||||
lastY = y;
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}, { passive: true });
|
||||
header.style.transition = 'transform 0.25s ease';
|
||||
}
|
||||
})();
|
||||
@@ -1,785 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>Scarf Updates</title>
|
||||
<link>https://awizemann.github.io/scarf/appcast.xml</link>
|
||||
<description>Scarf macOS app updates</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Version 2.8.0</title>
|
||||
<sparkle:version>35</sparkle:version>
|
||||
<sparkle:shortVersionString>2.8.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Sat, 09 May 2026 19:02:51 +0000</pubDate>
|
||||
<description><![CDATA[
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1d1d1f;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin: 16px 0 6px 0;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 4px 0;
|
||||
color: #424245;
|
||||
}
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 2px 0;
|
||||
}
|
||||
p { margin: 6px 0; }
|
||||
ul { margin: 6px 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
code {
|
||||
background: #f5f5f7;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f7;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e5e7;
|
||||
margin: 16px 0;
|
||||
}
|
||||
strong { color: #1d1d1f; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
h2 { border-bottom-color: #38383a; }
|
||||
h3 { color: #c7c7cc; }
|
||||
code, pre { background: #2c2c2e; }
|
||||
hr { border-top-color: #38383a; }
|
||||
a { color: #4499ff; }
|
||||
strong { color: #f5f5f7; }
|
||||
}
|
||||
</style></head><body>
|
||||
<h2>What's in 2.8.0</h2>
|
||||
<p>A coordinated catch-up release adopting Hermes v0.13.0 (v2026.5.7) — "The Tenacity Release" — across Scarf's full surface area. v2.8.0 ships <strong>Persistent Goals</strong>, <strong>ACP <code>/queue</code></strong>, <strong>Kanban diagnostics + recovery UX</strong>, <strong>Curator archive/prune</strong>, <strong>Google Chat (20th platform) + cross-platform allowlists</strong>, a refreshed <strong>provider catalog</strong> with five new models, and a slate of settings + UX polish — all behind capability flags so pre-v0.13 hosts continue to render the v2.7.5 surface unchanged.</p>
|
||||
<p>No data migrations, no schema changes. <code>~/.hermes/state.db</code> columns are unchanged from v0.11/v0.12. Existing <code>~/.hermes/scarf/</code> sidecars are untouched. Sparkle picks the update up automatically.</p>
|
||||
<h3>New features</h3>
|
||||
<h4>Persistent Goals + ACP <code>/queue</code> (chat)</h4>
|
||||
<ul>
|
||||
<li><strong><code>/goal <text></code> slash command</strong> — locks the agent on a target that persists across turns. Surfaced via the chat slash menu (gated on <code>HermesCapabilities.hasGoals</code>) and rendered as an <code>info</code>-tinted "Goal locked: …" pill in the chat header. The pill exposes a "Clear goal" context-menu item that dispatches <code>/goal --clear</code>. Optimistic local mirror — Hermes is the authoritative owner; Scarf paints the pill the moment the user sends <code>/goal …</code> so the affordance feels instant.</li>
|
||||
<li><strong><code>/queue <text></code> slash command</strong> — queues a prompt to run after the current turn completes. Joins <code>/steer</code> and <code>/goal</code> in <code>RichChatViewModel.nonInterruptiveCommands</code> (the chat keeps "Agent working…" off when sent). A header chip shows the queued count; tap opens a popover listing prompts + relative timestamps. Per-entry deletion isn't exposed (Hermes has no remove-by-id verb), and the popover header makes that explicit so users understand the local mirror's role.</li>
|
||||
<li><strong><code>/steer</code> on idle</strong> — pre-v0.13 was a no-op when no turn was in flight; v0.13 runs it as a regular prompt. The composer's slash button now greys <code>/steer</code> only on pre-v0.13 hosts (gated on <code>hasACPSteerOnIdle</code>).</li>
|
||||
<li><strong>Static slash-menu fallbacks</strong> — pre-session, the menu surfaces <code>/new</code> (with optional <code>[<name>]</code> argument hint on v0.13). Active-session-only fallbacks (<code>/clear</code>, <code>/compact</code>, <code>/cost</code>, <code>/model</code>, <code>/tools</code>, <code>/reload-skills</code>, <code>/help</code>, <code>/exit</code>) round out resumed sessions where Hermes ACP doesn't re-emit <code>available_commands_update</code> after <code>session/load</code>. Deduped against the ACP-advertised set so the canonical entry always wins once a session opens.</li>
|
||||
</ul>
|
||||
<h4>Kanban v0.13 diagnostics + recovery UX</h4>
|
||||
<ul>
|
||||
<li><strong>Hallucination-gate verify / reject</strong> — worker-created cards land with <code>hallucination_gate_status: pending</code>. The inspector renders a yellow banner ("Created by a worker — verify before running") with a Verify and Reject button. Cards in pending state dim 0.6 with a yellow ⚠ glyph in the title row.</li>
|
||||
<li><strong>Diagnostics rendering</strong> — new typed-mirror enum <code>KanbanDiagnosticKind</code> with severity (info / warning / critical). Per-task and per-run diagnostics surface in the inspector Runs tab as chip-lists; auto-block reasons render verbatim in the existing red banner. Darwin zombie detections show as a distinct <code>darwin_zombie_detected</code> kind.</li>
|
||||
<li><strong>Per-task <code>max_retries</code></strong> — added to the create sheet (default 3) and shown as a header chip in the inspector. Write-once at create time, matching Hermes's pattern.</li>
|
||||
<li><strong>Multiline title/body</strong> — the create sheet's Title field accepts multiline input, capped to four visible rows.</li>
|
||||
<li><strong>Tolerant decoding</strong> — every new field uses <code>decodeIfPresent</code>. Pre-v0.13 JSON parses cleanly with the new fields defaulting to nil, and the v2.7.5 board surface is unchanged on older hosts.</li>
|
||||
</ul>
|
||||
<h4>Curator archive + prune</h4>
|
||||
<ul>
|
||||
<li><strong>Archived skills section</strong> in <code>CuratorView</code> showing <code>hermes curator list-archived</code> output. Each row exposes Restore (returns to the active leaderboard) and Prune (destructive — opens a custom confirm sheet matching the template-uninstall pattern, with <code>ScarfDestructiveButton</code> "Prune permanently" and Cancel as the default keyboard action).</li>
|
||||
<li><strong>Bulk prune</strong> — a header action (gated on archived list non-empty) that enumerates every archived skill in the confirm sheet before a single-tap destructive action. Per-skill prune buttons are present per row when Hermes supports <code>prune <name></code>; otherwise only the bulk action is exposed.</li>
|
||||
<li><strong>Synchronous "Run Now"</strong> — v0.13 <code>hermes curator run</code> blocks until done. The Run Now button shows a progress affordance for the duration; pre-v0.13 falls back to fire-and-forget.</li>
|
||||
<li><strong>New <code>CuratorService</code> actor</strong> in ScarfCore (<a href="scarf/Packages/ScarfCore/Sources/ScarfCore/Services/CuratorService.swift">scarf/Packages/ScarfCore/Sources/ScarfCore/Services/CuratorService.swift</a>) — pure-I/O Sendable actor mirroring <code>KanbanService</code>'s shape, with defensive <code>--json</code> retry-without-flag fallback for verbs that may not support it on all v0.13 patch releases.</li>
|
||||
<li>The legacy <code>CuratorRestoreSheet</code> flow (SAFE-list-restore for v0.12) is preserved; it predates the v0.13 archive surface and serves a distinct case.</li>
|
||||
</ul>
|
||||
<h4>Messaging Gateway expansion</h4>
|
||||
<ul>
|
||||
<li><strong>Google Chat</strong> — 20th platform. New entry in the Mac Platforms tab, gated on <code>HermesCapabilities.hasGoogleChatPlatform</code>.</li>
|
||||
<li><strong>Cross-platform allowlists</strong> — per-platform editor for <code>allowed_channels</code> (Slack / Mattermost / Google Chat), <code>allowed_chats</code> (Telegram / WhatsApp), and <code>allowed_rooms</code> (Matrix / DingTalk). New <code>AllowlistEditor</code> component plus the <code>GatewayAllowlistKind</code> / <code>GatewayPlatformSettings</code> ScarfCore types. Persisted to <code>~/.hermes/config.yaml</code> via a new <code>GatewayConfigWriter</code> since <code>hermes config set</code> doesn't write list blocks.</li>
|
||||
<li><strong>Per-platform behavior toggles</strong> — <code>busy_ack_enabled</code> (suppress per-message "agent is working…" acks), <code>gateway_restart_notification</code> (post a "Gateway restarted" notice on boot), and a slash-command auto-delete TTL (seconds, 0 to disable). Each appears in the new <code>GatewayBehaviorSection</code> component.</li>
|
||||
<li><strong><code>hermes gateway list</code> cross-profile digest</strong> — inline status row in <code>MessagingGatewayView</code> showing which profile is running which platform across all profiles. New <code>HermesGatewayListService</code> actor parses <code>hermes gateway list --json</code>. Hidden when the verb fails (pre-v0.13 hosts) or no profiles are registered.</li>
|
||||
<li><strong><code>MessagingGatewayViewModel</code></strong> — internal rename from <code>GatewayViewModel</code> to disambiguate from the v0.10 Tool Gateway feature. The user-facing label was already "Messaging Gateway" since v0.10.</li>
|
||||
<li><strong><code>[[as_document]]</code> hint</strong> — informational tooltip in skill detail surfaces explaining the new media-routing directive for skills that reference it.</li>
|
||||
</ul>
|
||||
<h4>Provider catalog refresh</h4>
|
||||
<ul>
|
||||
<li><strong>Five new models</strong> — <code>deepseek/deepseek-v4-pro</code>, <code>x-ai/grok-4.3</code>, <code>openrouter/owl-alpha</code> (free tier), <code>tencent/hy3-preview</code>, and <code>arcee/trinity-large-thinking</code> (with temperature + compression overrides). Surfaced through <code>models_dev_cache.json</code>; no manual entries required.</li>
|
||||
<li><strong>Grok rename</strong> — <code>x-ai/grok-4.20-beta</code> → <code>x-ai/grok-4.20</code>. Implemented via read-time alias resolution in <code>ModelCatalogService.modelAliases</code> so existing user configs with the <code>-beta</code> suffix keep validating without YAML rewrites. Three composite-keyed aliases cover the openrouter / xai / vercel routes.</li>
|
||||
<li><strong>Vercel AI Gateway demoted</strong> — sort comparator change in <code>loadProviders()</code> puts Vercel last, after the alphabetical group.</li>
|
||||
<li><strong><code>image_gen.model</code> honored</strong> — pre-v0.13 the key was advertised but ignored; v0.13 actually drives the image-generation path. Surfaced in <code>Settings → Auxiliary</code> with a curated picker (<code>OpenAI gpt-image-1</code>, <code>Imagen 3/4</code>, <code>Stable Image Ultra</code>, <code>FLUX 1.1 Pro</code>, <code>DALL·E 3</code>); free-form entry is also accepted. Gated on <code>hasImageGenModel</code>.</li>
|
||||
<li><strong>OpenRouter response caching</strong> — toggle in <code>Settings → Auxiliary</code> writing <code>openrouter.response_cache.enabled</code> to <code>config.yaml</code>. Off by default in Scarf's parser. Gated on <code>hasOpenRouterResponseCache</code>.</li>
|
||||
</ul>
|
||||
<h4>Settings tab additions</h4>
|
||||
<ul>
|
||||
<li><strong>MCP SSE transport</strong> — MCP add-server flow gains a Transport picker (<code>stdio</code> / <code>http</code> / <code>sse</code>) with <code>sse_read_timeout</code> field for SSE servers. The YAML round-trip preserves OAuth + headers identically to the existing <code>.http</code> shape. Gated on <code>hasMCPSSETransport</code>.</li>
|
||||
<li><strong>Cron <code>--no-agent</code> watchdog mode</strong> — toggle in the Cron edit sheet that maps to <code>hermes cron create/update --no-agent</code>. When ON, the prompt + context fields hide (the AI call is skipped). Defensive write-path strips the flag on pre-v0.13 hosts mirroring the <code>--workdir</code> pattern. New <code>HermesCronJob.noAgent: Bool</code> field with <code>decodeIfPresent</code> so pre-v0.13 reads keep parsing. Gated on <code>hasCronNoAgent</code>.</li>
|
||||
<li><strong>Web Tools per-capability backends</strong> — new <code>Settings → Web Tools</code> tab with separate pickers for <code>web_search</code> and <code>web_extract</code>. SearXNG appears in the search picker only. The legacy single <code>web_tools.backend</code> is still readable for round-trip safety on mixed-version installs. Gated on <code>hasWebToolsBackendSplit</code>.</li>
|
||||
<li><strong>Profiles <code>--no-skills</code></strong> — "Empty profile (no skills)" toggle in the create-profile flow that appends <code>--no-skills</code> to <code>hermes profile create</code>. Disabled when "Clone all" is on (mutually exclusive). Gated on <code>hasProfileNoSkills</code>.</li>
|
||||
</ul>
|
||||
<h4>UX polish</h4>
|
||||
<ul>
|
||||
<li><strong>Context compression count</strong> in the chat status bar. v0.13 emits the count alongside the token tally on the <code>session/prompt</code> response; Scarf renders a <code>🗜 ×N</code> chip next to the token count when <code>count > 0</code>. Gated on <code>hasContextCompressionCount</code>.</li>
|
||||
<li><strong><code>/new <name></code> argument hint</strong> — bracket-aware so v0.13 hosts show <code>[<name>]</code> and pre-v0.13 hosts show no hint.</li>
|
||||
<li><strong><code>HermesUpdaterCommandBuilder</code></strong> — forward-compat plumbing for <code>hermes update --yes</code>. No in-app surface in v2.8.0 (Scarf doesn't currently expose a "Run hermes update" command); the builder is wired so a future Settings affordance can opt in cleanly.</li>
|
||||
<li><strong>Redaction default-flip awareness</strong> — the existing <code>Settings → Advanced → Redaction</code> toggle hint copy now branches on <code>HermesCapabilities.isV013OrLater</code>. v0.13+ hosts read "Recommended: ON. Hermes v0.13 defaults to redacting secrets unless you opt out"; pre-v0.13 keeps the v2.7 hint.</li>
|
||||
<li><strong><code>display.language</code> picker</strong> — new <code>Settings → General → Locale</code> row. 8 options: default, zh, ja, de, es, fr, uk, tr. Hermes does the actual translation; Scarf just persists <code>display.language</code> to <code>config.yaml</code>. Gated on <code>hasDisplayLanguage</code>.</li>
|
||||
<li><strong>xAI Custom Voices badge</strong> — <code>Settings → Voice</code> shows a "Cloning supported" <code>ScarfBadge</code> next to the xAI TTS provider entry. Informational only; voice management itself happens via <code>hermes voice</code> CLI. Gated on <code>hasXAIVoiceCloning</code>.</li>
|
||||
</ul>
|
||||
<h4>ScarfGo iOS catch-up (read-only)</h4>
|
||||
<p>Following the Phase H precedent, iOS mirrors selected v2.8 surfaces as read-only — write parity is deferred to v2.8.x.</p>
|
||||
<ul>
|
||||
<li><strong>Goal pill + queue chip</strong> in the iOS chat header (<code>projectContextBar</code>). Tap is a no-op; the Mac app owns mutations.</li>
|
||||
<li><strong>Kanban v0.13 diagnostics</strong> in <code>ScarfGoKanbanDetailSheet</code> — <code>retries: N</code> chip, "Worker-created — verify on Mac" hallucination badge, red <code>auto_blocked_reason</code> banner, tappable diagnostics chip-lists with severity-tinted badges and a new <code>DiagnosticDetailSheet</code> (replacing Mac's <code>.help()</code> tooltip on touch).</li>
|
||||
<li><strong>Curator Archived list</strong> in <code>Scarf iOS/Curator/CuratorView.swift</code> — read-only, with footer pointing users to the Mac app for Restore / Prune actions.</li>
|
||||
<li><strong>Settings → Platforms extension</strong> — Google Chat status row, busy-ack and restart-notification summary rows across <code>gatewayPlatforms</code> (handles disagreement with "mixed (N platforms)"), allowlist DisclosureGroups with monospaced "platform: id" entries when expanded.</li>
|
||||
<li><strong>"v0.13 features active" badge</strong> in iOS Settings (gated on <code>caps.isV013OrLater</code>). Tap presents <code>V013FeaturesSheet</code> listing the new affordances.</li>
|
||||
</ul>
|
||||
<h3>Capability gating</h3>
|
||||
<p>v2.8.0 adds 22 new flags on <code>HermesCapabilities</code> (each gating one v0.13 surface), plus an <code>isV013OrLater</code> convenience predicate. Every new affordance is gated; pre-v0.13 hosts see the v2.7.5 surface byte-identical to before. The HermesVersionBanner threshold remains pre-v0.12 — v0.12 → v0.13 nudging happens via the iOS Settings badge (positive surface) rather than a global yellow banner (which was reserved for "missing every new feature" cases).</p>
|
||||
<h3>Bug fixes uncovered during v0.13.0 dogfooding</h3>
|
||||
<ul>
|
||||
<li><strong>Dashboard flicker on v0.13 hosts</strong> — Hermes v0.13 writes to <code>state.db-wal</code> and rotating logs at ~10 Hz during gateway activity. Each FSEvents fire ticked <code>lastChangeDate</code>, every observing view re-fired its load handler against it, and on Local hosts the dashboard stacked 5+ concurrent <code>dashboardSnapshot</code> calls in 200 ms — sqlite contention on the read-only handle surfaced as <code>BackendError error 3</code>, plus visible flicker. Two-part fix: <code>HermesFileWatcher.scheduleCoalescedTick</code> coalesces FSEvents into one observable mutation per 500 ms quiet window with a 1.5 s max-wait floor (so a coincident <code>gateway_state.json</code> Start/Stop touch can't be starved indefinitely under sustained WAL writes); <code>DashboardViewModel.load()</code> holds a single in-flight <code>Task<Void, Never></code> handle so concurrent triggers await the in-flight load instead of stacking.</li>
|
||||
<li><strong>Sparse slash menu on resumed sessions</strong> — Hermes ACP only emits <code>available_commands_update</code> after <code>session/new</code>, not after <code>session/load</code>. Combined with <code>RichChatViewModel.reset()</code> clearing <code>acpCommands</code> on every session switch, resumed sessions landed at a 4-command fallback even though the agent identity hadn't changed. Fix: stop wiping <code>acpCommands</code> in <code>reset()</code> (they're agent-level, not session-level), and add an active-session-only static fallback set covering the standard agent commands so cold-start LOAD users see a rich menu immediately.</li>
|
||||
</ul>
|
||||
<h3>Migrating from 2.7.5</h3>
|
||||
<p>Sparkle delivers the update automatically. No config migration, no schema changes — same <code>~/.hermes/state.db</code> columns as v0.11/v0.12, same Scarf-owned sidecars at <code>~/.hermes/scarf/</code>. Existing v2.7.5 Kanban tenants stay valid; existing project manifests are unchanged. Settings tabs grow new rows; existing rows render identically.</p>
|
||||
<p>If you're connecting to a Hermes v0.13.0 host for the first time after this update, the new surfaces light up automatically — no flag flip in the app. Pre-v0.13 hosts continue to render the v2.7.5 surface; nothing breaks if you upgrade Scarf before upgrading Hermes.</p>
|
||||
<h3>Known limitations</h3>
|
||||
<ul>
|
||||
<li><strong>iOS write surfaces</strong> (Verify hallucination gate, Reject, Curator archive/prune actions, allowlist editor, <code>/goal</code> send, <code>/queue</code> send) are explicitly out of scope for v2.8.0 and slated for v2.8.x. iOS surfaces are read-only mirrors per the Phase H precedent.</li>
|
||||
<li><strong>Auto-resumed-from-checkpoint indicator</strong> — Hermes v0.13's "auto-resume after gateway restart" feature is server-side; whether the ACP adapter advertises a Scarf-visible signal is unclear pending live host verification. Deferred to v2.8.1.</li>
|
||||
<li><strong>xAI voice cloning management UX</strong> — only the "Cloning supported" badge ships in v2.8.0. A full voice-management surface is a follow-up.</li>
|
||||
<li><strong>Bulk re-tag for legacy NULL-tenant Kanban tasks</strong> — carryover from v2.7.5; Hermes still has no <code>tenant</code> mutation verb post-create.</li>
|
||||
<li><strong>Cluster A wire-shape TODOs</strong> — 25 <code>// TODO(WS-N-Q<n>)</code> markers across the codebase flag fields and CLI flags whose exact shape couldn't be verified from release notes alone. Each has a tolerant-decode default that fails closed (hides the affordance rather than throwing); a pre-merge sweep on a v0.13 host can confirm or fix each in seconds.</li>
|
||||
</ul>
|
||||
<h3>Acknowledgements</h3>
|
||||
<p>v2.8.0 was driven by a 9-stream coordinated multi-agent build: WS-1 capability flag foundation through WS-9 iOS catch-up, with planning artifacts archived under <a href="scarf/docs/v2.8/">scarf/docs/v2.8/</a> for future reference. Bug fixes for the dashboard flicker and sparse-slash-menu issues were caught during a fresh end-to-end dogfood pass against a live Hermes v0.13.0 install — the kind of surface-level UX bugs that only show up under real-world <code>state.db-wal</code> write rates and real-world resume flows. As always, real bugs come from doing instead of speculating.</p>
|
||||
</body></html>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.8.0/Scarf-v2.8.0-Universal.zip"
|
||||
sparkle:edSignature="m5HQUKgxfWa5u88gEVCGWMIKaogBIsjPspQG97y1KcrW1w6S5XF1s0v1oRaRWMyIlj46BD+937Inu2Ii5TbXAg=="
|
||||
length="19771149"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.7.5</title>
|
||||
<sparkle:version>34</sparkle:version>
|
||||
<sparkle:shortVersionString>2.7.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Fri, 08 May 2026 10:56:09 +0000</pubDate>
|
||||
<description><![CDATA[
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1d1d1f;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin: 16px 0 6px 0;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 4px 0;
|
||||
color: #424245;
|
||||
}
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 2px 0;
|
||||
}
|
||||
p { margin: 6px 0; }
|
||||
ul { margin: 6px 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
code {
|
||||
background: #f5f5f7;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f7;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e5e7;
|
||||
margin: 16px 0;
|
||||
}
|
||||
strong { color: #1d1d1f; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
h2 { border-bottom-color: #38383a; }
|
||||
h3 { color: #c7c7cc; }
|
||||
code, pre { background: #2c2c2e; }
|
||||
hr { border-top-color: #38383a; }
|
||||
a { color: #4499ff; }
|
||||
strong { color: #f5f5f7; }
|
||||
}
|
||||
</style></head><body>
|
||||
<h2>What's in 2.7.5</h2>
|
||||
<p>A feature release that lifts Scarf's Kanban surface from a read-only list (the v2.6 placeholder shipped while upstream Kanban was still mid-rework) to a full drag-and-drop board with the complete Hermes v0.12 mutation surface wired up — plus per-project boards bound to a Scarf-minted tenant slug, and a read-only board on iOS for at-a-glance status from your phone. No data migrations, no schema changes; pre-v0.12 hosts gracefully hide the surface.</p>
|
||||
<h3>New features</h3>
|
||||
<h4>Mac</h4>
|
||||
<ul>
|
||||
<li><strong>Drag-and-drop Kanban board</strong> (<a href="scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift">scarf/Features/Kanban/Views/KanbanBoardView.swift</a>). Five visible columns — Triage / Up Next (<code>todo</code> + <code>ready</code>) / Running / Blocked / Done — collapsing Hermes's seven status values into a layout that doesn't waste space on <code>ready</code>, which the dispatcher only ever holds for a few seconds. Triage hides itself when empty; archived hides behind a header toggle. Drop a card onto a column and Scarf maps the gesture to the right Hermes verbs through a pure transition planner: drop-on-Running fires <code>kanban dispatch</code> (the dispatcher then spawns a worker), drop-on-Blocked opens a sheet asking for a reason and calls <code>kanban block</code>, drop-on-Done opens a result sheet and calls <code>kanban complete</code>, blocked → running chains <code>unblock</code> + <code>dispatch</code>. Forbidden transitions (anything dropped on Done; anything dragged out of Triage) reject with a red drop-target stroke and a tooltip explaining why — Done is terminal, Triage is promoted by a specifier worker, neither has a CLI verb that maps cleanly. Optimistic local updates apply on drop and revert on CLI failure with a toast, so the UI feels instant.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Side-pane inspector</strong> (<a href="scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift">KanbanInspectorPane.swift</a>). Click a card and a 420 px pane slides in from the trailing edge. Not a modal sheet — modal would block triaging the next card after closing. Header carries the status, an inline assignee menu (more on that below), workspace kind, and tenant; below that, four tabs render <code>hermes kanban show <id></code> data: <strong>Comments</strong> (with an inline composer that calls <code>kanban comment</code>), <strong>Events</strong> (the <code>task_events</code> log with per-kind glyphs), <strong>Runs</strong> (one row per attempt with outcome badge + summary + error), and <strong>Log</strong> — the worker's captured stdout/stderr from <code>hermes kanban log <id></code>, polled every 2 s while the task is running with a "● streaming" indicator and auto-scroll to the latest line, snapshot-only with a refresh button when the task is in a terminal state. The action bar at the bottom has all the per-status verbs — Start (which is <code>claim</code> rebranded as a user-visible action), Complete, Block, Unblock, Archive — every one with a help tooltip explaining what it does and what Hermes verb it invokes. The "Archive" tooltip explicitly notes Hermes has no hard-delete: archived tasks remain in <code>~/.hermes/kanban.db</code> and are recoverable via the "Show archived" toggle until <code>hermes kanban gc</code> runs.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Inspector auto-refresh.</strong> While the inspector is open, the detail (header, action buttons, comments, events, runs) re-fetches every 5 s on the same cadence as the board itself, so a worker transition (e.g. running → done elsewhere) is reflected without the user having to close + reopen. The Log tab's 2 s poll runs separately and self-cancels the moment the task transitions out of <code>running</code>.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Inline assignee picker on the inspector header.</strong> The assignee badge is a clickable menu — set means a <code>.brand</code> (rust) chip, unassigned means a <code>.warning</code> (yellow) chip so the eye catches it instantly. Tapping opens a menu of every known profile (union of <code>~/.hermes/profiles/</code>, current task assignees, and the active local profile from <code>HermesProfileResolver</code>) plus an "Unassigned" option. Selection routes through <code>kanban assign</code> and immediately follows with <code>kanban dispatch</code> so the task gets picked up promptly. Solves the "I assigned a profile but nothing happened" gap end-to-end without the user touching a terminal.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Health banner in the inspector.</strong> Surfaces two conditions that previously left users staring at a stuck task with no explanation. <strong>Yellow</strong> when the task is unassigned in <code>ready</code> / <code>todo</code>: <em>"Won't run automatically — Hermes's dispatcher silently skips tasks with no assignee."</em> The dispatcher's own <code>--json</code> output literally lists these under <code>skipped_unassigned</code>; we now surface that to the human. <strong>Red</strong> when the most-recently-completed run ended in a non-success outcome (<code>stale_lock</code> / <code>crashed</code> / <code>gave_up</code> / <code>timed_out</code> / <code>spawn_failed</code> / <code>reclaimed</code> / <code>failed</code>): banner displays the outcome label + the raw <code>error</code> field from the run record, so you don't have to dig into the Runs tab to discover it. The red banner is suppressed while a fresh attempt is running — once status flips back to <code>running</code>, the previous outcome is stale signal and the Log tab's live stream is the right thing to look at.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Card-level signals.</strong> Cards in <code>running</code> get a 2 px <code>ScarfColor.info</code> left edge + a subtle title shimmer so live work is obvious at a glance. Blocked cards get a 2 px <code>ScarfColor.warning</code> left edge + a ⚠ glyph next to the title. Done cards dim to 0.7 opacity in light mode, 0.55 in dark, with a green ✓ in the title row. Cards in <code>ready</code> / <code>todo</code> with no assignee get a yellow ⚠ glyph in the title row with a tooltip explaining the dispatcher won't pick them up — same signal as the inspector banner, just at the board level so triage is one keypress away.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>Board | List</code> toggle at the top of the route.</strong> The v2.6 read-only list view is preserved in <code>KanbanListView.swift</code> and surfaced via a segmented picker, so users on narrow windows or anyone who prefers a flat sortable list can opt in. Choice persists across launches via <code>@AppStorage</code>.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>New Task sheet</strong> (<a href="scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift">KanbanCreateSheet.swift</a>). Title, body (markdown supported), assignee (defaults to <code>HermesProfileResolver.activeProfileName()</code> so newly-created tasks actually run), workspace kind (segmented <code>Scratch / Worktree / Project Dir</code>; locked to Project Dir on per-project boards), priority slider, comma-separated skills with autocomplete from <code>~/.hermes/skills/</code>, optional tenant (hidden on per-project boards — the slug is implicit), and a "Send to triage" toggle. Submit fires <code>kanban create --json</code> and immediately follows with <code>kanban dispatch</code> so an assigned task transitions <code>ready</code> → <code>running</code> within seconds rather than waiting for the gateway dispatcher's internal cycle.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Kanban moved from Manage → Monitor in the sidebar.</strong> It's runtime work-in-progress, not configuration. Sits between Activity and the rest of Manage so users see "what's happening right now" at a glance.</li>
|
||||
</ul>
|
||||
<h4>Per-project Kanban</h4>
|
||||
<ul>
|
||||
<li><strong><code>DashboardTab.kanban</code> on every project</strong>, capability-gated on <code>HermesCapabilities.hasKanban</code>. Renders a project-scoped <code>KanbanBoardView</code> filtered to the project's tenant slug. Workspace defaults in the New Task sheet are pre-pinned to <code>dir:<project.path></code>. Empty state explains the project doesn't have any tasks yet and offers a "New Task" CTA — the empty board IS the discovery surface.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Tenant minting via <a href="scarf/scarf/Core/Services/KanbanTenantResolver.swift">KanbanTenantResolver</a>.</strong> Each Scarf project gets a stable <code>scarf:<slug></code> tenant minted on first kanban interaction and persisted to <code><project>/.scarf/manifest.json</code> (new optional <code>kanbanTenant</code> field on <code>ProjectTemplateManifest</code>). Slug rules: lowercased, hyphenated, ≤ 48 chars, <code>scarf:</code> prefix to avoid collision with hand-typed tenants. Once minted, the tenant is <strong>immutable across rename</strong> — tasks already on the board carry the original slug, so renaming the project doesn't orphan them. Bare projects (no manifest) get a sentinel manifest written with <code>id: scarf/<project-id></code> + <code>version: 0.0.0</code> + just the <code>kanbanTenant</code> set; the <code>ProjectAgentContextService</code> reader recognizes the sentinel and refuses to surface it as a "Template" line in the AGENTS.md block, so the project doesn't suddenly start advertising a fake template to the agent.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Agent-side tenant injection.</strong> <a href="scarf/scarf/Core/Services/ProjectAgentContextService.swift">ProjectAgentContextService.renderBlock</a> emits a "Kanban tenant" line inside the <code><!-- scarf-project --></code> markers in <code><project>/AGENTS.md</code> whenever a tenant exists, instructing the agent to pass <code>--tenant scarf:<slug></code> on <code>hermes kanban create</code>. <code>ChatViewModel.startACPSession</code> already calls <code>refresh(for:)</code> before opening every project chat, so the agent reads a fresh tenant on every session start with no extra wiring. Agents are imperfect at flag discipline; a forgotten <code>--tenant</code> lands the task in the global "Untagged" group rather than failing — acceptable v2.7.5 behavior.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>kanban_summary</code> dashboard widget</strong> (<a href="scarf/scarf/Features/Projects/Views/Widgets/KanbanSummaryWidgetView.swift">KanbanSummaryWidgetView.swift</a>). New widget kind for project dashboards: shows the top three <code>running</code> / <code>blocked</code> / <code>todo</code> tasks for the project's tenant by priority, plus a glance footer (<code>"12 todo · 3 running · 5 blocked"</code>) sourced from <code>kanban stats</code>. Polls every 10 s while the dashboard is foregrounded. Widget vocabulary registered in <a href="tools/widget-schema.json">tools/widget-schema.json</a> and rendered on the catalog site via <a href="site/widgets.js">site/widgets.js</a>; template authors can drop a <code>{ kind: kanban_summary, max_rows: 3 }</code> block into <code>dashboard.json</code>.</li>
|
||||
</ul>
|
||||
<h4>iOS / iPadOS</h4>
|
||||
<ul>
|
||||
<li><strong>Read-only Kanban tab on <code>ProjectDetailView</code></strong> (<a href="scarf/Scarf%20iOS/Kanban/ScarfGoKanbanView.swift">Scarf iOS/Kanban/ScarfGoKanbanView.swift</a>). Same five-column collapse rendered as a horizontally-paged segmented <code>Picker</code> of single-column lists — HIG-friendly on iPhone where a 5-column grid forces unreadable card widths. Pulls live status, assignee, workspace, skills, priority chips. Tap a card → modal <code>NavigationStack</code> detail sheet (<a href="scarf/Scarf%20iOS/Kanban/ScarfGoKanbanDetailSheet.swift">ScarfGoKanbanDetailSheet.swift</a>) with the same Comments / Events / Runs tabs the Mac inspector has. Read-only in v2.7.5 — mutations + drag-drop on iPad land in v2.8 once the Mac flow is fully shaken out. Card titles use semantic <code>.headline</code> (not <code>ScarfFont</code>) so Dynamic Type works; chrome (badges) stays on <code>ScarfBadge</code> for fixed visual weight per the project's iOS conventions.</li>
|
||||
</ul>
|
||||
<h4>ScarfCore</h4>
|
||||
<ul>
|
||||
<li><strong><code>KanbanService</code> actor</strong> (<a href="scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift">Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift</a>) — pure-I/O Sendable actor wrapping every Hermes v0.12 verb (<code>list / show / runs / stats / assignees / create / assign / claim / comment / complete / block / unblock / archive / dispatch / link / unlink / log</code>). Dispatches each CLI invocation through <code>Task.detached(priority: .utility)</code> matching the existing concurrency conventions. Errors land in <a href="scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanError.swift">KanbanError</a> and surface as inline banners (not modal alerts) since the board is high-frequency. The "no matching tasks" stdout sentinel is normalized to <code>[]</code> rather than thrown.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Pure transition planner.</strong> <code>KanbanService.plan(for: KanbanTransition)</code> is a synchronous function that maps a <code>(from, to)</code> column pair to the right verb sequence — <code>(.upNext, .running) → [.dispatch]</code>, <code>(.blocked, .running) → [.unblock, .dispatch]</code>, etc. Disallowed transitions throw <code>KanbanError.forbiddenTransition</code> with a user-actionable reason. The planner is fully tested in <code>KanbanModelsTests.swift</code>. Critically: <code>dispatch</code> (not <code>claim</code>) is the verb used for Up-Next → Running. Hermes's <code>claim</code> is documented as "manual alternative to the dispatcher" and assumes the caller spawns the worker themselves — Scarf doesn't, so calling <code>claim</code> from drag-drop reserved tasks but never spawned work, and the dispatcher reclaimed them ~15 minutes later (<code>stale_lock</code>). <code>dispatch</code> is the right primitive for a GUI client.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Cross-platform <a href="scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanTenantReader.swift">KanbanTenantReader</a>.</strong> Read-only projection over <code><project>/.scarf/manifest.json</code>'s <code>kanbanTenant</code> field. The full <code>ProjectTemplateManifest</code> type lives in the Mac target; this lightweight reader gives iOS a way to filter the per-project board by tenant without linking the full manifest model.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Timestamp decoding tolerates both shapes.</strong> Hermes emits <code>created_at</code> / <code>started_at</code> / <code>completed_at</code> / <code>last_heartbeat_at</code> etc. as Unix integer seconds (its SQLite columns are INTEGER), but earlier wire docs implied ISO-8601 strings. The decoder now accepts either an integer or a string and normalizes to ISO-8601 so downstream code only handles one type. Locked in by <code>decodeUnixIntegerTimestamps</code> in <code>KanbanModelsTests</code>.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>KanbanBoardViewModel</code> optimistic merge.</strong> Holds <code>optimisticOverrides: [taskId: status]</code> for in-flight drags; the polled response merges with optimistic state until the server confirms the new status, so a stale poll arriving milliseconds after a drop can't snap the card back to its old column. On CLI failure the override is removed and the message lands in the inline banner.</li>
|
||||
</ul>
|
||||
<h3>Dispatch + assignee fixes</h3>
|
||||
<p>A diagnostic round driving real tasks end-to-end exposed a connected bug pattern that the polish pass closed:</p>
|
||||
<ul>
|
||||
<li><strong>Hermes's dispatcher silently skips unassigned tasks</strong> — its <code>kanban dispatch --json</code> output literally lists them under a <code>skipped_unassigned</code> key and moves on. Tasks created without an assignee sat in <code>ready</code> indefinitely and the user had no signal anything was wrong. The New Task sheet now defaults to the active Hermes profile, the inspector header shows a yellow "Unassigned" chip + warning banner, every <code>ready</code> / <code>todo</code> card without an assignee gets a ⚠ glyph + tooltip, and the inspector's inline assignee picker fixes it in one click.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>Drag-to-Running used to call <code>claim</code></strong>, which is a manual alternative to the dispatcher. Status flipped to <code>running</code>, but no worker spawned (Scarf doesn't host workers), and 15 minutes later the dispatcher reclaimed the task with a <code>stale_lock</code> outcome. Replaced with <code>dispatch</code> end-to-end so the gateway-running dispatcher actually does the spawning.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>hermes kanban assignees</code> empty-state was leaking into the picker.</strong> The CLI prints a literal sentinel <code>(no assignees — create a profile with hermes -p <name> setup)</code> when the table is empty; the parser was tokenizing it on whitespace and offering <code>(no</code> as a profile in the menu. Parser now skips the sentinel, validates each candidate against <code>^[a-zA-Z0-9_-]+$</code>, and falls back cleanly to the active local profile when the table is empty.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>spawn_failed</code> from "executable not found on PATH"</strong> — most subtle of the lot. macOS GUI apps inherit a launch-services PATH (<code>/usr/bin:/bin:/usr/sbin:/sbin</code>) that doesn't include <code>~/.local/bin</code> (where pipx installs <code>hermes</code>) or <code>/opt/homebrew/bin</code>. Scarf was finding <code>hermes</code> for its own invocation via the absolute-path resolver in <code>HermesPathSet.hermesBinaryCandidates</code>, but when the dispatcher then spawned a worker process, that worker inherited Scarf's GUI PATH and couldn't find <code>hermes</code> by name — recording an <code>outcome=spawn_failed</code> run with the exact "executable not found on PATH" message. <code>LocalTransport</code> now grows an <code>environmentEnricher</code> static (mirroring <code>SSHTransport.environmentEnricher</code>) wired by <code>scarfApp.swift</code> to the same <code>HermesFileService.enrichedEnvironment()</code> login-shell probe the SSH transport uses. Every local subprocess Scarf spawns now sees the user's full PATH and credential env, so a spawned-from-Scarf hermes can spawn its children by name without reaching for absolute paths. Defense-in-depth: <code>subprocessEnvironment(forExecutable:)</code> also unconditionally prepends the executable's parent directory to PATH, so the fix works even if the enricher hasn't been wired (early startup, tests).</li>
|
||||
</ul>
|
||||
<h3>Migrating from 2.7.1</h3>
|
||||
<p>Sparkle will offer the update automatically. No config migration, no schema changes — <code>~/.hermes/kanban.db</code> is shared across all Hermes clients and Scarf only reads/writes through the documented CLI surface. Existing Scarf projects pick up the new project Kanban tab on first open; the tenant slug is minted lazily on first kanban interaction inside the project, so projects with no kanban activity stay byte-identical until the user opens the tab.</p>
|
||||
<p>If you have an existing project with a Scarf-managed <code>manifest.json</code>, the new optional <code>kanbanTenant</code> field is added on next mint and lives alongside any template-author config schema without touching it. Templates do not ship <code>kanbanTenant</code> (it's user-machine-scoped state); the export pipeline strips it.</p>
|
||||
<p>If you've been running tasks via the v2.6 read-only list and your Hermes host already runs the gateway dispatcher, your existing kanban tasks should appear on the board automatically — there's no migration step. Tasks created without an assignee in v2.6 will now show the yellow "Unassigned" warning until you fix them through the inline picker.</p>
|
||||
<h3>Known limitations</h3>
|
||||
<ul>
|
||||
<li><strong>Within-column reorder is not supported.</strong> Hermes has no <code>update</code> verb and no <code>position</code> column on the tasks table — <code>priority</code> is write-once at create time. Sort order inside each column is <code>priority DESC, created_at DESC</code>, matching the dispatcher's actual run order. We considered a client-side ordering sidecar; rejected because the on-screen order would diverge from what runs next, which is worse than no manual order. Will revisit if Hermes ships an <code>update --priority</code> verb.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>No live <code>watch</code> streaming yet.</strong> The board polls every 5 s; the inspector polls detail on the same cadence and the Log tab on a 2 s cadence while running. <code>hermes kanban watch --json</code> event streaming + reconnect-with-backoff lands in v2.8 along with iOS write surfaces.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong>No bulk re-tag for legacy NULL-tenant tasks.</strong> Tasks created before this release (assignee or no assignee) appear in the global "Untagged" group on the global board. Hermes has no <code>tenant</code> mutation verb post-create, so retagging would be archive + recreate — too destructive to ship in this release.</li>
|
||||
</ul>
|
||||
<h3>Acknowledgements</h3>
|
||||
<ul>
|
||||
<li>Driven end-to-end against a fresh local Hermes v0.12.0 install with the gateway dispatcher running. Real bug surface mostly came from doing instead of speculating: the <code>claim</code> vs <code>dispatch</code> distinction, the silent <code>skipped_unassigned</code> behavior, the <code>(no</code> parse leak, the integer-vs-ISO timestamp shape, and the stale "Last run" banner during a fresh attempt all surfaced from driving real tasks and watching what actually happened.</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.7.5/Scarf-v2.7.5-Universal.zip"
|
||||
sparkle:edSignature="6QLmonqLdavcU3+u7NE3oYL4Iui4wZZ0r9OWM+kj0uJ3tM32C14N35g7kXmADvo50YONIAmxqfkYWo/AIX70AA=="
|
||||
length="19346988"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.7.1</title>
|
||||
<sparkle:version>33</sparkle:version>
|
||||
<sparkle:shortVersionString>2.7.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Thu, 07 May 2026 10:51:54 +0000</pubDate>
|
||||
<description><![CDATA[
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1d1d1f;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin: 16px 0 6px 0;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 4px 0;
|
||||
color: #424245;
|
||||
}
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 2px 0;
|
||||
}
|
||||
p { margin: 6px 0; }
|
||||
ul { margin: 6px 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
code {
|
||||
background: #f5f5f7;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f7;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e5e7;
|
||||
margin: 16px 0;
|
||||
}
|
||||
strong { color: #1d1d1f; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
h2 { border-bottom-color: #38383a; }
|
||||
h3 { color: #c7c7cc; }
|
||||
code, pre { background: #2c2c2e; }
|
||||
hr { border-top-color: #38383a; }
|
||||
a { color: #4499ff; }
|
||||
strong { color: #f5f5f7; }
|
||||
}
|
||||
</style></head><body>
|
||||
<h2>What's in 2.7.1</h2>
|
||||
<p>A patch release covering three bug reports filed against 2.7.0, plus follow-up cleanups in the same neighborhood. No data migrations, no UI surface changes — drop-in replacement for 2.7.0 on Mac.</p>
|
||||
<h3>Bug fixes</h3>
|
||||
<h4>Mac</h4>
|
||||
<ul>
|
||||
<li><strong><a href="https://github.com/awizemann/scarf/issues/77">#77</a> — Sessions screen renders empty even when Dashboard reports sessions exist.</strong> v2.7.0 folded the Sessions tab's two SQL queries (sessions list + previews) into a single batched SSH round-trip for perf. The combined wire payload for any user with ~150+ sessions crossed macOS's 16–64 KB pipe-buffer threshold; without a concurrent reader draining the pipe, the remote <code>sqlite3 -json</code> blocked, the script never finished, our 30-second timeout fired, and the call returned an empty result. <code>SSHScriptRunner</code> now drains stdout/stderr concurrently with the running process via <code>FileHandle.readabilityHandler</code>, so the kernel pipe never fills. Same fix applied to the local-execution path. New regression test pushes 256 KB of synthetic output through the runner and asserts full delivery — would have wedged pre-fix.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><a href="https://github.com/awizemann/scarf/issues/78">#78</a> — Skills "What's New" pill contradicts the Updates sub-tab.</strong> The pill at the top of the Skills page was rendering on every sub-tab, including Updates. It counts <strong>local</strong> file deltas since the user last clicked "Mark as seen" (e.g. "18 new" = 18 skills landed on disk that you haven't acknowledged), while the Updates body runs <code>hermes skills check</code> to find skills with newer <strong>upstream</strong> versions available — a different concept. Two surfaces using the word "update" for two different things made the screen contradict itself. Two changes: the pill now renders only on the Installed sub-tab (Mac and ScarfGo), and its label says "X <strong>changed</strong> since you last looked" instead of "X updated" so the local-file vocabulary doesn't collide with upstream-update vocabulary anywhere on the page.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><a href="https://github.com/awizemann/scarf/issues/79">#79</a> — Skills hub search returns nothing for terms visible in Browse.</strong> With the source picker on "All Sources", <code>hermes skills search <query></code> (no <code>--source</code> flag) routes through Hermes's centralized index and skips external API sources (skills-sh, github, clawhub, lobehub, well-known) — but Browse still aggregates from those sources, so a skill like <code>honcho</code> would show up in Browse and disappear in search. Same picker, same query, contradictory results. Rather than chase Hermes's index gaps, "All Sources" search now means "filter what you can already see": Scarf caches the most recent Browse payload and runs a client-side substring filter (case-insensitive against name, description, and identifier) against it, instantly. Source-specific searches still shell out to <code>hermes skills search --source <s></code> for full upstream search semantics. Five new tests cover the filter behavior.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>hermesPIDResult()</code> — narrow the Hermes "is it running?" probe to the gateway.</strong> Previously <code>pgrep -f hermes</code>, which matched any process with "hermes" in its argv: chat sessions Scarf itself spawns, <code>hermes -z</code> one-shots, log tails, even the README in an editor. The Dashboard "Hermes is running" badge could read true even when the gateway daemon was down. Tightened to a regex that matches only the gateway shape — <code>python -m hermes_cli.main gateway run …</code> and <code>/path/to/hermes gateway run …</code>. All callers (DashboardViewModel, HealthViewModel, SettingsViewModel, scarfApp, stopHermes) want the gateway PID specifically. Cherry-picked from <a href="https://github.com/awizemann/scarf/pull/76">#76</a> — thanks to <a href="https://github.com/unixwzrd">@unixwzrd</a> for the diagnosis and regex.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>HealthViewModel.stopDashboard()</code> — stop the dashboard by port, not <code>pkill -f</code>.</strong> External-instance fallback used to be <code>pkill -f "hermes dashboard"</code>, broad enough to match shell history, log tails, README readers — anything with the substring in its argv. Now <code>lsof -tiTCP:<port> -sTCP:LISTEN</code> resolves the PID actually bound to the dashboard port and only that one process gets <code>SIGTERM</code>. Trusting the port is correct here: Scarf owns the configured port and the user-visible intent is "stop the thing on this port." Direction cherry-picked from <a href="https://github.com/awizemann/scarf/pull/76">#76</a>; the <code>-c hermes</code> filter from the original was dropped because Hermes installs as a Python shebang script and the kernel COMM is <code>python</code>, not <code>hermes</code> — <code>-c hermes</code> would silently miss every standard install.</li>
|
||||
</ul>
|
||||
<h3>Documentation + tooling</h3>
|
||||
<ul>
|
||||
<li><strong><code>scripts/local-build.sh</code> + <code>BUILDING.md</code> for contributor builds.</strong> New unsigned single-arch Debug build script for contributors without an Apple Developer account. Detects arm64 / x86_64, verifies xcode-select / xcrun / xcodebuild, probes the Metal toolchain (offers an interactive install on TTY, errors cleanly on CI), resolves Swift packages, builds Debug with signing disabled. Optional one-touch <code>ditto</code> to <code>/Applications/scarf.app</code> on explicit y/N. The canonical Release universal CLI in <code>README.md</code> is unchanged — <code>local-build.sh</code> is an alternative for contributors, not a replacement for the shipping build. Cherry-picked from <a href="https://github.com/awizemann/scarf/pull/76">#76</a>.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><strong><code>BUILDING.md</code> + <code>CONTRIBUTING.md</code> — restored Sonoma compatibility messaging.</strong> The runtime min is <strong>macOS 14.6 (Sonoma)</strong> — that's the <code>MACOSX_DEPLOYMENT_TARGET</code> on the main <code>scarf</code> target and is intentional. Build min is <strong>Xcode 16.0</strong> (needed for Swift 6 strict-concurrency features). The legacy CONTRIBUTING.md line had drifted to "Xcode 26.3+ / macOS 26.2+", which would have steered Sonoma contributors and users away from a build that actually runs on their box. Corrected, with a load-bearing-callout in BUILDING.md so future doc edits don't silently raise the floor again.</li>
|
||||
</ul>
|
||||
<h3>Migrating from 2.7.0</h3>
|
||||
<p>Sparkle will offer the update automatically. No config migration, no schema changes. Existing sessions, skills, and projects are untouched.</p>
|
||||
<p>If you've been working around #77 by collapsing the sidebar or restarting Scarf to repopulate the Sessions list, you can stop — sessions should load reliably now.</p>
|
||||
<h3>Acknowledgements</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/bricelb">@bricelb</a> for the three v2.7.0 bug reports (<a href="https://github.com/awizemann/scarf/issues/77">#77</a>, <a href="https://github.com/awizemann/scarf/issues/78">#78</a>, <a href="https://github.com/awizemann/scarf/issues/79">#79</a>) — well-instrumented reproductions including screenshots and environment details made the diagnosis straightforward.</li>
|
||||
<li><a href="https://github.com/unixwzrd">@unixwzrd</a> for <a href="https://github.com/awizemann/scarf/pull/76">#76</a> — the gateway-pgrep tighten, the <code>pkill -f "hermes dashboard"</code> direction, and the <code>local-build.sh</code> contributor flow are all cherry-picked from that PR.</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.7.1/Scarf-v2.7.1-Universal.zip"
|
||||
sparkle:edSignature="P9whuwJ264TN1XLaUNvzQK+yKmK2fiyccgKa6TixK3mkIPyVl2XrwPhyHgoMFOQ/c14l+4sizWolx67BSwXBAg=="
|
||||
length="18806277"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.7.0</title>
|
||||
<sparkle:version>32</sparkle:version>
|
||||
<sparkle:shortVersionString>2.7.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Tue, 05 May 2026 18:47:18 +0000</pubDate>
|
||||
<description><![CDATA[
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1d1d1f;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
margin: 16px 0 6px 0;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 14px 0 4px 0;
|
||||
color: #424245;
|
||||
}
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 2px 0;
|
||||
}
|
||||
p { margin: 6px 0; }
|
||||
ul { margin: 6px 0; padding-left: 20px; }
|
||||
li { margin: 3px 0; }
|
||||
code {
|
||||
background: #f5f5f7;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f7;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e5e7;
|
||||
margin: 16px 0;
|
||||
}
|
||||
strong { color: #1d1d1f; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
h2 { border-bottom-color: #38383a; }
|
||||
h3 { color: #c7c7cc; }
|
||||
code, pre { background: #2c2c2e; }
|
||||
hr { border-top-color: #38383a; }
|
||||
a { color: #4499ff; }
|
||||
strong { color: #f5f5f7; }
|
||||
}
|
||||
</style></head><body>
|
||||
<h2>What's in 2.7.0</h2>
|
||||
<p>The biggest release since 2.6.0 — a six-week stretch covering <strong>remote-context performance</strong>, a <strong>new project authoring flow</strong>, <strong>dashboard widgets</strong>, <strong>OAuth resilience</strong>, and a top-to-bottom <strong>performance instrumentation harness</strong> that drove the bulk of the rest. 36 commits, no schema bump, no Hermes capability bump.</p>
|
||||
<p>The throughline: Scarf got materially faster and more honest on slow remote SSH links, where 30-second sqlite timeouts and silently-empty UI used to be common. The skeleton-then-hydrate pattern, SSH cancellation propagation, and ScarfMon-driven diagnosis are the shape of how that work gets done now.</p>
|
||||
<hr>
|
||||
<h3>Remote-context performance — chats and Activity in seconds, not 30s timeouts</h3>
|
||||
<p>Resuming a chat on a slow remote (a 420ms-RTT droplet, an underprovisioned VPS, a tunnel through 4G) used to fetch the full message column set in one shot, which routinely tripped the 30s SSH timeout on chats with multi-page tool result blobs. The 160-message session was broken; the 30-message session was broken too. Activity didn't load at all.</p>
|
||||
<p>v2.7 introduces a <strong>skeleton-then-hydrate pattern</strong> that bounds the wire payload by what the user actually needs to see RIGHT NOW, then fills in the heavy stuff in the background:</p>
|
||||
<ul>
|
||||
<li><strong>Chat skeleton.</strong> <a href="https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift"><code>fetchSkeletonMessages</code></a> selects user + assistant rows only (skips <code>role='tool'</code>) with <code>tool_calls</code> / <code>reasoning</code> / <code>reasoning_content</code> hard-NULLed at the SQL level. Wire payload bounded by conversational text alone — typically a few KB. The chat appears in seconds. Background <code>startToolHydration</code> pages through <code>hydrateAssistantToolCalls</code> in 5-id batches to splice tool calls in. Tool-result CONTENT is <strong>opt-in</strong> via Settings → Display → "Load tool results in past chats" (default off); the inspector pane lazy-fetches per-result content via <code>fetchToolResult(callId:)</code> when you open a card.</li>
|
||||
<li><strong>Activity skeleton.</strong> <a href="https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift"><code>fetchRecentToolCallSkeleton</code></a> returns metadata-only rows (id + session_id + role + timestamp; everything else NULLed). Activity opens in <1s on remote with placeholder rows; real per-call entries swap in as paged hydration completes. New "Loading tool details…" pill in the page header surfaces hydration progress.</li>
|
||||
<li><strong>Single-id whale recovery.</strong> When a 5-id batch trips the 30s timeout (one row carries an oversized <code>tool_calls</code> blob — a long Edit's args, a big diff), an L1 single-id retry isolates the offending row so the rest of the batch still hydrates. Whale row stays bare; assistant message stays readable.</li>
|
||||
<li><strong>Lazy tool result loading in the inspector.</strong> Default-off avoids the bulk fetch. When you focus a tool call card, ChatInspectorPane fires <code>loadToolResultIfMissing(callId:)</code> which splices a single result into the message stream without re-fetching anything else.</li>
|
||||
</ul>
|
||||
<p>Effect: a 160-message thinking-model session that used to time out at exactly 30s now opens in under 2 seconds with placeholder cards filling in over the next few. Activity loads in 500-800ms.</p>
|
||||
<h4>SSH cancellation that actually cancels</h4>
|
||||
<p><code>Task.detached { … }</code> doesn't inherit cancellation from the awaiting parent, and <code>Task<…> { … }</code> (unstructured) also drops the signal. Without explicit bridging, cancelling a chat-load Task only unwinds Swift state — the underlying ssh subprocess kept running for the full 30s, pinning a remote sqlite query and a ControlMaster session slot. This produced the "third chat hangs" / "dashboard spins after rapid switching" symptom.</p>
|
||||
<p>v2.7 wires <code>withTaskCancellationHandler</code> through <a href="https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift"><code>SSHScriptRunner.run</code></a> and <a href="https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift"><code>RemoteSQLiteBackend.query</code></a> so parent cancellation reaches the <code>Process</code> and calls <code>proc.terminate()</code> within 100ms. New <code>ssh.cancelled</code> ScarfMon event surfaces this.</p>
|
||||
<h4>In-flight coalescing for <code>loadRecentSessions</code></h4>
|
||||
<p>File-watcher deltas during an active stream used to stack 2-3 parallel sessions-list reload tasks (the 500ms <code>scheduleSessionsRefresh</code> debounce only suppresses a pending tick, not one already executing). Subsequent callers now await the in-flight load instead of spawning a parallel SSH subprocess. New <code>mac.loadRecentSessions.coalesced</code> event tracks dedup hits.</p>
|
||||
<h4>Loading-state UX hardening</h4>
|
||||
<p>The Mac chat sidebar greys out and disables row taps the moment a session-switch is initiated (synchronously, before <code>client.start()</code> returns), with a floating ProgressView showing the current phase: <strong>"Spawning hermes acp…"</strong> → <strong>"Authenticating…"</strong> → <strong>"Loading session…"</strong> → <strong>"Loading history…"</strong> → <strong>"Ready"</strong>. Pre-fix the sidebar looked engageable while the 5-7 second SSH+ACP boot was still in flight, and the user could queue up a second session-switch behind the first. New <code>isStartingSession</code> flag flips on user click for instant feedback.</p>
|
||||
<h4>Partial-result + mismatch + pinned-model banners</h4>
|
||||
<ul>
|
||||
<li><strong>Partial-result banner.</strong> When the skeleton fetch trips an SSH transport failure (rather than a clean empty result), the chat surfaces "Couldn't load full chat history — the connection to <em>server</em> timed out" through the existing <code>acpError</code> triplet, plus forces <code>hasMoreHistory = true</code> so the "Load earlier" affordance shows up. Replaces the pre-fix silent empty transcript.</li>
|
||||
<li><strong>Model/provider mismatch banner.</strong> <a href="https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift"><code>ModelPreflight.detectMismatch</code></a> recognizes when <code>model.default</code> carries a <code><provider>/...</code> prefix that disagrees with <code>model.provider</code> (e.g. <code>anthropic/claude-sonnet-4.6</code> + <code>provider: nous</code> after switching OAuth via Credential Pools). Banner offers one-click fix in either direction.</li>
|
||||
<li><strong>Pinned-model failure hint.</strong> ACP error classifier now recognizes <code>model_not_found</code> / <code>404 messages</code> / <code>model is not available</code> and surfaces "This session was created with a model the provider no longer offers — start a new chat to use your current model" so the pinned-model failure mode has a clear recovery path.</li>
|
||||
<li><strong>OAuth-completion provider swap.</strong> After a successful OAuth in Credential Pools, if the just-authed provider differs from <code>model.provider</code>, surface "Switch active provider to <em>name</em>?" with [Switch] / [Keep current] instead of auto-dismissing.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h3>New Project from Scratch wizard + Keychain-backed cron secrets</h3>
|
||||
<p>A <strong>third project entry point</strong> alongside Browse Catalog and Add Existing Project: a wizard that scaffolds a Scarf-standard project skeleton (<code><project>/.scarf/dashboard.json</code> + AGENTS.md marker block), registers it, and hands off to a chat session that auto-activates the bundled <code>scarf-template-author</code> skill. The skill drives the rest conversationally — widgets, optional config schema, optional cron — and writes the final files itself. Wizard stays minimal because the agent does configuration better than a multi-step form. The skill ships bundled inside <code>Scarf.app/Contents/Resources/BuiltinSkills.bundle/</code> and copies into <code>~/.hermes/skills/</code> on launch (idempotent + version-gated).</p>
|
||||
<p><strong>Cron + Keychain — <code>$SCARF_<SLUG>_<FIELD></code> env vars.</strong> Cron prompts that referenced <code>secret</code>-typed config fields used to get the literal <code>keychain://...</code> URI back when reading <code>config.json</code>, producing 401s. v2.7 mirrors resolved Keychain values into <code>~/.hermes/.env</code> under a marker-bounded block keyed by template slug:</p>
|
||||
<pre><code># scarf-secrets:begin local-news-aggregator
|
||||
SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN=actual-value
|
||||
SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL=https://example.com/feed
|
||||
# scarf-secrets:end local-news-aggregator</code></pre>
|
||||
<p>Hermes already reloads <code>~/.hermes/.env</code> per cron tick, so credential rotation is automatic — just edit the value in Configuration → next tick sees it. The mirror runs at every state-change point: install, post-install Configuration save, uninstall, "Remove from List", and on app launch (reconciliation pass over registered projects). Source of truth stays in the Keychain — <code>config.json</code> keeps <code>keychain://</code> URIs unchanged. Mode 0600 enforced on <code>~/.hermes/.env</code>.</p>
|
||||
<p>Cron prompts now reference these env vars directly:</p>
|
||||
<pre><code>{
|
||||
"prompt": "Use the terminal: curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN\" \"$SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml"
|
||||
}</code></pre>
|
||||
<p><strong>Migration.</strong> First launch of v2.7 walks the project registry and writes the managed block per schemaful project — automatic. Existing cron prompts you wrote against the old (broken) <code>config.json</code> pattern still need updating: open the cron job in Scarf's Cron sidebar and edit the prompt, or ask the agent in chat ("Update my Local News cron job's prompt to use the new env var convention") — the bundled <code>scarf-template-author</code> skill (now v1.1.0) documents the convention with worked examples.</p>
|
||||
<p>Also fixes <a href="https://github.com/awizemann/scarf/issues/75">#75</a> — <code>_NSDetectedLayoutRecursion</code> on the Configuration form for projects whose form transitioned between stages with different intrinsic heights.</p>
|
||||
<hr>
|
||||
<h3>Project dashboards — file-reading widgets, sparklines, typed status</h3>
|
||||
<p>Five new widget types, project-wide auto-refresh, and a structured error card for unknown widgets. Backwards-compatible — every existing <code>dashboard.json</code> renders byte-identically.</p>
|
||||
<ul>
|
||||
<li><strong>Project-wide auto-refresh.</strong> <a href="https://github.com/awizemann/scarf/blob/main/scarf/scarf/Core/Services/HermesFileWatcher.swift"><code>HermesFileWatcher</code></a> used to watch each project's <code>dashboard.json</code> specifically. v2.7 promotes that to a watch on the entire <code><project>/.scarf/</code> directory. A <code>markdown_file</code> or <code>log_tail</code> widget pointing at <code><project>/.scarf/reports/foo.md</code> refreshes the moment a cron job rewrites the file. <strong>By convention, place files the dashboard reads inside <code>.scarf/</code></strong> so the watch picks them up.</li>
|
||||
<li><strong><code>markdown_file</code></strong> — renders a markdown file from disk through the same <code>MarkdownContentView</code> pipeline used by inline <code>text</code> widgets.</li>
|
||||
<li><strong><code>log_tail</code></strong> — last <code>lines</code> of a file (default 20, max 200), monospaced, ANSI codes stripped.</li>
|
||||
<li><strong><code>cron_status</code></strong> — last run / next run / state for one Hermes cron job by <code>jobId</code>, plus a small inline log tail. Read-only — Run/Pause/Resume controls stay on the Cron tab.</li>
|
||||
<li><strong><code>image</code></strong> — local file (<code>path</code> relative to project root) or remote <code>url</code>. Optional <code>height</code> cap. Useful for matplotlib/Plotly PNGs the cron job generates.</li>
|
||||
<li><strong><code>status_grid</code></strong> — compact NxM grid of colored cells, one per service / item, with hover labels.</li>
|
||||
<li><strong><code>stat</code> widget gains inline sparklines.</strong> Optional <code>sparkline: [Number]</code> field. SVG-only render, dozens per dashboard cost nothing.</li>
|
||||
<li><strong>Typed status badges.</strong> <code>list</code> items and <code>status_grid</code> cells share a typed enum (<code>success</code>, <code>warning</code>, <code>danger</code>, <code>info</code>, <code>pending</code>, <code>done</code>, <code>neutral</code>) with lenient decode for synonyms (<code>ok</code>/<code>up</code> → success, <code>down</code>/<code>error</code> → danger). Unknown strings render as plain text.</li>
|
||||
<li><strong>Structured widget error card.</strong> Replaces the legacy "Unknown: \<type\>" placeholder with a card surfacing the title, specific reason, and a hint.</li>
|
||||
<li><strong>Schema mirror.</strong> The widget vocabulary lives once at <a href="https://github.com/awizemann/scarf/blob/main/tools/widget-schema.json"><code>tools/widget-schema.json</code></a>; the catalog validator reads from it and enforces per-type required fields.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h3>OAuth resilience + Credential Pools</h3>
|
||||
<ul>
|
||||
<li><strong>Daily OAuth keepalive cron.</strong> Prevents Anthropic OAuth refresh tokens from expiring after weeks of inactivity. New cron job <code>[scarf:oauth-keepalive]</code> (managed by Scarf) pings Hermes on a daily cadence; the in-app Refresh All Sessions action mirrors the same path on demand.</li>
|
||||
<li><strong>Remote re-auth.</strong> Re-authenticating against a remote droplet's OAuth provider used to be blocked by the lack of a stdin path through SSHTransport. The OAuth flow now drives a remote <code>hermes auth add</code> correctly with stdin forwarded.</li>
|
||||
<li><strong>OAuth remove button.</strong> Per-provider remove action in Credential Pools (auth.json edit), with confirmation dialog. Companion auto-refresh of the view when <code>auth.json</code> changes externally (file-watcher).</li>
|
||||
<li><strong><code>resolve_provider_client</code> error classification.</strong> When an auxiliary task references a provider whose credentials aren't loaded, Hermes prints <code>resolve_provider_client: <name> requested but <Display Name> not configured</code> to stderr — pre-fix this surfaced in chat as the opaque <code>-32603 Internal error</code> with no actionable detail. Now classified into a clear hint pointing at Settings → Aux Models.</li>
|
||||
<li><strong>Aux Tab unknown-task surface.</strong> When <code>config.yaml</code> has an <code>auxiliary.<task></code> block for a task Scarf doesn't know about (newer Hermes added it; Scarf hasn't caught up), render it as a plain row with the raw provider/model values instead of dropping it silently.</li>
|
||||
<li><strong>Credential Pools refresh after OAuth sheet dismiss.</strong> Closing the OAuth sheet after a successful add now refreshes the list immediately instead of leaving the just-added pool hidden until the next file-watcher tick.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h3>ScarfMon — performance instrumentation harness</h3>
|
||||
<p>The diagnostic surface that drove the bulk of the v2.7 perf work. Off by default; signpost-only mode (Instruments-friendly) is free; Full mode (4096-entry in-memory ring buffer + os.Logger) is a click away in Settings → Diagnostics → Performance. Wiki: https://github.com/awizemann/scarf/wiki/Performance-Monitoring</p>
|
||||
<ul>
|
||||
<li><strong>Phases 1-3</strong> built the core: dispatcher + ring buffer + 3 backends, chat / transport / sqlite measure points, diagnostic counters for chat-render bursts, finalize-burst dampening.</li>
|
||||
<li><strong>Tier A + B</strong> added per-feature instrumentation: iOS file watcher, sessions list, model catalog, dashboard widgets, image encoder, message hydration.</li>
|
||||
<li><strong>Nous picker investigation</strong> localized a 60s + 120s beach-ball to a specific path (Nous catalog <code>readCache</code>), then killed the 120s one with dedupe + 5s timeout.</li>
|
||||
<li><strong>Tier C catch-up</strong> (this release): instrumented Memory / Skills / Cron / Curator load paths so future captures show how often these tabs cost multiple sequential SFTP RTTs on remote.</li>
|
||||
<li><strong>Per-call bytes recorded</strong> on transport + sqlite events so captures show payload sizes alongside latencies.</li>
|
||||
<li><strong><code>mac.emptyAssistantTurn</code> event</strong> documents the Nous quirk where the model returns a thought stream with no body (the bubble looks like Hermes is "still thinking" but the turn already finished).</li>
|
||||
</ul>
|
||||
<p>Adding a new measure point is two lines. The harness covers Mac and iOS uniformly. The "Copy as JSON" button exports the ring buffer for paste-into-issue diagnosis.</p>
|
||||
<hr>
|
||||
<h3>Other fixes + polish</h3>
|
||||
<ul>
|
||||
<li><strong>Sessions sidebar reload debounce</strong> — file-watcher deltas during streaming used to flicker the sessions list. Coalesced into one trailing fetch ~500ms after the last tick.</li>
|
||||
<li><strong>Session-load pagination + race guard</strong> — switching to a small chat while a larger one is mid-fetch could last-write-wins the small chat away. Three race-checks against <code>self.sessionId</code> prevent the stale fetch from overwriting.</li>
|
||||
<li><strong>Sessions + previews batched</strong> — two separate SSH calls folded into one <code>queryBatch</code> round trip, halving the round-trips for every sidebar refresh.</li>
|
||||
<li><strong>Remote SQLite query timeout</strong> bumped 15→30s to better tolerate slow links; in-flight query coalescing dedupes concurrent identical queries.</li>
|
||||
<li><strong><code>Thread.sleep</code> spin replaced</strong> with a kernel-wait via <code>DispatchGroup</code> for <code>runLocal</code> timeout; under concurrent SSH load the old loop accumulated spin-blocked threads and produced 7-second outliers in <code>loadRecentSessions</code>.</li>
|
||||
<li><strong>Window position + size</strong> persists across launches.</li>
|
||||
<li><strong>Sidebar reorder</strong> — Projects promoted to first section; profile chip moved under server name.</li>
|
||||
<li><strong><code>stop</code> badge suppressed</strong> on metadata footer for normal turn ends (it was firing for every clean completion, looking like an error).</li>
|
||||
<li><strong>Nous picker search field</strong> + <code>model-picker</code> filter for the long Nous overlay model list.</li>
|
||||
<li><strong><code>oauth-keepalive</code> cron create</strong> — drop the <code>--silent</code> flag Hermes doesn't accept.</li>
|
||||
<li><strong>Snapshot pipeline rewritten</strong> — replaced the <code>sqlite3 .backup</code>-then-download pipeline with direct SSH-streamed query execution (issue <a href="https://github.com/awizemann/scarf/issues/74">#74</a>). Eliminates the multi-minute snapshot wait on multi-GB state.db files. Companion fix: pre-expand <code>~/</code> in Swift via <code>resolvedUserHome</code> so sqlite3 finds the DB without depending on the remote shell's tilde expansion.</li>
|
||||
<li><strong>Aux nested-YAML parser</strong> — corrected the parser so the unknown-task surface works on remote (was previously dropping aux blocks whose <code>provider:</code> value lived on a separate line).</li>
|
||||
<li><strong><code>ModelPreflight</code> newline trim bug</strong> — <code>.whitespaces</code> doesn't strip newlines; switched both trims to <code>.whitespacesAndNewlines</code> so a stray <code>\n</code> in a hand-edited config.yaml doesn't false-positive the mismatch banner.</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h3>What's measured today</h3>
|
||||
<p>321 ScarfCore tests pass (302 prior + 19 new ModelPreflight). New ScarfMon events documented in the <a href="https://github.com/awizemann/scarf/wiki/Performance-Monitoring">Performance-Monitoring wiki</a>.</p>
|
||||
<h3>Compatibility</h3>
|
||||
<ul>
|
||||
<li>macOS 14+ (unchanged).</li>
|
||||
<li>Hermes target: still <strong>v2026.4.30 (v0.12.0)</strong>. No new Hermes capability gates added.</li>
|
||||
<li>Existing <code>dashboard.json</code> files render unchanged.</li>
|
||||
<li>Existing <code>.scarftemplate</code> bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.</li>
|
||||
<li>Existing <code>~/.hermes/.env</code> content is preserved byte-identically — Scarf only writes inside its <code># scarf-secrets:begin <slug></code> / <code># scarf-secrets:end <slug></code> regions.</li>
|
||||
<li>The skeleton-then-hydrate chat loader and SSH cancellation propagation are <strong>Mac-only</strong> in this release; ScarfGo (iOS) keeps its existing chat path.</li>
|
||||
</ul>
|
||||
<h3>What's deferred</h3>
|
||||
<ul>
|
||||
<li><strong>Per-widget data sources + per-widget refresh granularity.</strong> The general "widget points at a typed data source" abstraction is the next-largest win in dashboards but materially expands the model + JS mirror + validator surface. The project-wide watch covers the common cron-driven workflow without it.</li>
|
||||
<li><strong>Cross-project health digest sidebar rollup.</strong> Counting attention-needed projects across the registry — scoped but didn't pull its weight. The typed status enum makes it cheap to add later.</li>
|
||||
<li><strong>Automatic cron-prompt rewriter on upgrade.</strong> Heuristic rewrites of free-form prompts are risky; the docs + agent-assisted path ships in v2.7. Revisit a "scan + fix" UI in v2.8 if real users miss the migration.</li>
|
||||
<li><strong>iOS New Project wizard + iOS Keychain-env mirror.</strong> ScarfGo's project surface is read-only; the wizard's chat-handoff pattern depends on Mac-only ACP plumbing.</li>
|
||||
<li><strong>iOS skeleton-then-hydrate loaders.</strong> Same data-service surfaces are public, but the iOS chat lifecycle is structured differently. Defer until iOS dogfooding shows the same payload-size pain.</li>
|
||||
<li><strong>Tier C redesigns (Memory/Skills/Cron/Curator).</strong> Instrumented in v2.7; redesign waits for capture data showing which path actually needs the skeleton-then-hydrate treatment.</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.7.0/Scarf-v2.7.0-Universal.zip"
|
||||
sparkle:edSignature="JfHK1sKbP3ubbznXx3uY/a3kY2szkWpTUQmtHJE54Uv970T5PxgIHCTwsimqtZCPepUTv2qK4TRQfqHJBKUmCA=="
|
||||
length="18793474"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.6.5</title>
|
||||
<sparkle:version>31</sparkle:version>
|
||||
<sparkle:shortVersionString>2.6.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Sun, 03 May 2026 20:20:29 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.6.5/Scarf-v2.6.5-Universal.zip"
|
||||
sparkle:edSignature="2hYRNX/z+mQ7TjlHfDAiTHiP/Rk1BoJRjjefPvutooiptwOK6EyYq/9crnHYZYe8xvEQTDMmAvsPz4vYz823Cg=="
|
||||
length="18257858"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.6.0</title>
|
||||
<sparkle:version>29</sparkle:version>
|
||||
<sparkle:shortVersionString>2.6.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Fri, 01 May 2026 13:48:15 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.6.0/Scarf-v2.6.0-Universal.zip"
|
||||
sparkle:edSignature="gI1uwJAvmwdlvngLcsSQFtMDsHyI6+DWN23ZjOX/BYmX+BqKu0XLuAXL1tztZV9RF1l5PG+vNVWGnq6ivqVQCQ=="
|
||||
length="18031081"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.5.2</title>
|
||||
<sparkle:version>28</sparkle:version>
|
||||
<sparkle:shortVersionString>2.5.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Wed, 29 Apr 2026 11:47:40 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.5.2/Scarf-v2.5.2-Universal.zip"
|
||||
sparkle:edSignature="tPgE0ajkNs+OYuYGgB8jVLtY/tGTaUJlrJCf5I5AbwCU2R+Zu08D+pLB17q01A3AgwYfYTLZbnoTG+xO4ys8DQ=="
|
||||
length="17367761"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.5.1</title>
|
||||
<sparkle:version>27</sparkle:version>
|
||||
<sparkle:shortVersionString>2.5.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Mon, 27 Apr 2026 13:38:41 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.5.1/Scarf-v2.5.1-Universal.zip"
|
||||
sparkle:edSignature="OArax2dY25Q7ZRFYGcviaGmCQCJsIugcBdjTET//mJ4XTT/FnnPQoSTIYaQrsV+mwFZU//G75q9PwPtnNsPiCA=="
|
||||
length="17090983"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.5.0</title>
|
||||
<sparkle:version>26</sparkle:version>
|
||||
<sparkle:shortVersionString>2.5.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Sat, 25 Apr 2026 15:42:47 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.5.0/Scarf-v2.5.0-Universal.zip"
|
||||
sparkle:edSignature="YnHpGMIiL8jyDn3+h8B7Gqzrlz8SXDSyiUXGm9DD6BIkRfYfzi3AVatkxfBLMvoVhlqPGKIhKsB8ybqopgjpCw=="
|
||||
length="16994785"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.3.0</title>
|
||||
<sparkle:version>25</sparkle:version>
|
||||
<sparkle:shortVersionString>2.3.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Fri, 24 Apr 2026 01:20:51 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.3.0/Scarf-v2.3.0-Universal.zip"
|
||||
sparkle:edSignature="vae/hUTU7UOSY/LYU/pt1A9wnbKgvp22+e2peGA/clmloaA22gxCnBX5JALT1w93eHYMLOtvdSf5OrNHyogHDQ=="
|
||||
length="14783787"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.2.1</title>
|
||||
<sparkle:version>24</sparkle:version>
|
||||
<sparkle:shortVersionString>2.2.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Thu, 23 Apr 2026 20:10:10 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.2.1/Scarf-v2.2.1-Universal.zip"
|
||||
sparkle:edSignature="nFdO9t2wWWKeXehQCm7btr7kzCtmDDg4xsvrm4Z24fqOB+Y9ffYcIBr+e9pqnLSIJJ6r/lYcyz5FlkUMjYXyCw=="
|
||||
length="17868308"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.2.0</title>
|
||||
<sparkle:version>23</sparkle:version>
|
||||
<sparkle:shortVersionString>2.2.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Thu, 23 Apr 2026 16:31:53 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.2.0/Scarf-v2.2.0-Universal.zip"
|
||||
sparkle:edSignature="mGuKLJbcugMTKdSlrkgYKjpVAaXY8CsMVhCiAo/O7b4K8S/fRaK7ZZNdbLtfSGndrbVWcSU0IIhaB1rBGaPOBQ=="
|
||||
length="17867934"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.1.0</title>
|
||||
<sparkle:version>22</sparkle:version>
|
||||
<sparkle:shortVersionString>2.1.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Tue, 21 Apr 2026 01:50:30 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.1.0/Scarf-v2.1.0-Universal.zip"
|
||||
sparkle:edSignature="kllR3yC/Cze1W9fSM+WRIE5YVObubEGmV629hAvxzVhvVIJ9n+qa00WOAC3YakZLEKX46DmowEMQf5ikqQGODQ=="
|
||||
length="17243337"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.0.2</title>
|
||||
<sparkle:version>21</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Mon, 20 Apr 2026 22:50:02 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.0.2/Scarf-v2.0.2-Universal.zip"
|
||||
sparkle:edSignature="3BF7PzLqO835wr+cCEA0Ls4kfp2hNwgDmM8YlvUKM9R/GwTs+tgtSPLTwfr1UyXNH/83uNRuSeZM8iNgV4dPDA=="
|
||||
length="17063811"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 2.0.0</title>
|
||||
<sparkle:version>19</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Sun, 19 Apr 2026 20:11:43 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v2.0.0/Scarf-v2.0.0-Universal.zip"
|
||||
sparkle:edSignature="QTgPwFgHk5SbUYKrgg412o/Yc7ZhSiow/33EoWnN+julyEVw/0MwemjHNcwcX+aVQJV4arMp7xGVMzaVV/j1CQ=="
|
||||
length="16906164"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 1.6.2</title>
|
||||
<sparkle:version>18</sparkle:version>
|
||||
<sparkle:shortVersionString>1.6.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Sat, 18 Apr 2026 00:22:28 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v1.6.2/Scarf-v1.6.2-Universal.zip"
|
||||
sparkle:edSignature="wtf0QjTKCvYq8BZW1meeWdWk8GMsbYopfM/DNiYw8ImdgmX6X8jN5+bIG9KvzYv0VgZ0la8ssSliiz7zdJA2CQ=="
|
||||
length="16570465"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
<item>
|
||||
<title>Version 1.6.1</title>
|
||||
<sparkle:version>17</sparkle:version>
|
||||
<sparkle:shortVersionString>1.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
|
||||
<pubDate>Fri, 17 Apr 2026 02:11:53 +0000</pubDate>
|
||||
<enclosure url="https://github.com/awizemann/scarf/releases/download/v1.6.1/Scarf-v1.6.1-Universal.zip"
|
||||
sparkle:edSignature="hoYDb7VRQ+YDNUox1kf7eYbhckOJWYLEi8ZPfBZG59qK4L/5N2mmgV7jOCLriHkNx0F4mvM9UK8UQZIkxsX1DA=="
|
||||
length="16566934"
|
||||
type="application/octet-stream" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 429 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 750 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 600 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 750 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 412 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 790 KiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 591 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
@@ -1,684 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
|
||||
<title>Scarf — Native Mac & iOS app for your Hermes AI agent</title>
|
||||
<meta name="description" content="Scarf is the native macOS and iOS GUI for the Hermes AI agent — sessions, projects, memory, skills, cron, multi-server SSH. Free and open source.">
|
||||
<link rel="canonical" href="https://awizemann.github.io/scarf/">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Scarf">
|
||||
<meta property="og:title" content="Scarf — Native Mac & iOS app for your Hermes AI agent">
|
||||
<meta property="og:description" content="Native macOS and iOS GUI for the Hermes AI agent. Sessions, projects, memory, skills, cron, multi-server SSH. Free and open source.">
|
||||
<meta property="og:url" content="https://awizemann.github.io/scarf/">
|
||||
<meta property="og:image" content="https://awizemann.github.io/scarf/assets/og-image.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="Scarf — native Mac & iOS app for the Hermes AI agent">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Scarf — Native Mac & iOS app for your Hermes AI agent">
|
||||
<meta name="twitter:description" content="Native macOS and iOS GUI for the Hermes AI agent. Sessions, projects, memory, skills, cron, multi-server SSH.">
|
||||
<meta name="twitter:image" content="https://awizemann.github.io/scarf/assets/twitter-card.png">
|
||||
|
||||
<!-- Theme + favicons -->
|
||||
<meta name="theme-color" content="#C2563D" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#1A1818" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="icon" href="assets/icon-192.png" type="image/png" sizes="192x192">
|
||||
<link rel="icon" href="assets/icon-512.png" type="image/png" sizes="512x512">
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
|
||||
<!-- AI crawler hint -->
|
||||
<link rel="alternate" type="text/plain" title="LLM-friendly summary" href="llms.txt">
|
||||
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="site-header" role="banner">
|
||||
<a class="brand" href="./" aria-label="Scarf home">
|
||||
<img src="assets/scarf-icon-512.png" width="32" height="32" alt="" decoding="async">
|
||||
<span class="brand-name">Scarf</span>
|
||||
</a>
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#ios">iPhone</a>
|
||||
<a href="templates/">Templates</a>
|
||||
<a href="#download">Download</a>
|
||||
<a href="https://github.com/awizemann/scarf" rel="noopener">GitHub</a>
|
||||
</nav>
|
||||
<button type="button" class="theme-toggle" aria-label="Toggle dark mode" data-theme-toggle>
|
||||
<svg class="theme-icon icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path></svg>
|
||||
<svg class="theme-icon icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero" id="hero" aria-labelledby="hero-title">
|
||||
<div class="hero-copy">
|
||||
<h1 id="hero-title">Native Mac & iOS app<br>for your Hermes AI agent.</h1>
|
||||
<p class="hero-lede">
|
||||
Scarf is a native macOS and iOS GUI for the
|
||||
<a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes AI agent</a>.
|
||||
See every session, project, skill, memory file, and cron job — locally on your Mac
|
||||
and remotely from your iPhone over SSH.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="https://github.com/awizemann/scarf/releases/latest" rel="noopener">
|
||||
<span>Download for Mac</span>
|
||||
<span class="btn-meta">macOS 14.6+ · Apple Silicon & Intel</span>
|
||||
</a>
|
||||
<a class="btn btn-secondary" href="https://testflight.apple.com/join/qCrRpcTz" rel="noopener">
|
||||
<span>Get on iPhone</span>
|
||||
<span class="btn-meta">TestFlight · iOS 18+</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="hero-prereq">
|
||||
Requires <a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes</a> installed at <code>~/.hermes/</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-visual" aria-hidden="false">
|
||||
<picture class="hero-mac">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-hero-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-hero-dark.png" src="assets/screenshots/mac-hero.png"
|
||||
alt="Scarf on macOS — chat view streaming a response with a tool-call card"
|
||||
width="1600" height="1000"
|
||||
fetchpriority="high" decoding="async">
|
||||
</picture>
|
||||
<picture class="hero-iphone">
|
||||
<img src="assets/screenshots/ios-chat.png"
|
||||
alt="ScarfGo on iPhone — chat with the Hermes agent over SSH"
|
||||
width="430" height="932"
|
||||
decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trust strip -->
|
||||
<aside class="trust-strip" aria-label="At a glance">
|
||||
<ul>
|
||||
<li>Native Swift 6</li>
|
||||
<li>No Electron</li>
|
||||
<li>MIT licensed</li>
|
||||
<li>Sparkle auto-updates</li>
|
||||
<li>macOS 14.6+ · iOS 18+</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- What Scarf does (AEO load-bearing paragraph) -->
|
||||
<section class="what" id="what" aria-labelledby="what-title">
|
||||
<h2 id="what-title">What Scarf does</h2>
|
||||
<p>
|
||||
Scarf is a native macOS and iOS GUI for the Hermes AI agent. It surfaces every part of a
|
||||
running Hermes installation — chat sessions, project workspaces, memory files, installed
|
||||
skills, MCP servers, cron jobs, messaging gateways, logs, and configuration — through a
|
||||
sidebar-driven app on Mac and a tab-based companion on iPhone. Scarf reads from
|
||||
<code>~/.hermes/state.db</code> directly (read-only), streams agent replies in real time
|
||||
over the Agent Client Protocol, and connects to remote Hermes installations through your
|
||||
existing SSH config. There is no telemetry, no login, and no separate cloud service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Mac feature blocks -->
|
||||
<section class="features" id="features" aria-labelledby="features-title">
|
||||
<h2 id="features-title" class="section-heading">Built for the way Hermes actually works</h2>
|
||||
|
||||
<article class="feature feature-flip">
|
||||
<div class="feature-text">
|
||||
<h3>Live agent sessions</h3>
|
||||
<p>
|
||||
Real-time streaming chat over the Agent Client Protocol. Tool calls render inline with
|
||||
collapsible argument and output panes. Tool-permission requests surface as interactive
|
||||
dialogs you can approve, deny, or stage. Reasoning and thinking blocks display when the
|
||||
model emits them. Resume any prior session from the sidebar, or jump back into a
|
||||
conversation that disconnected — Scarf reconnects to the same session id.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-chat-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-chat-dark.png" src="assets/screenshots/mac-chat.png"
|
||||
alt="Mac app — chat view with a tool-call card and a streaming response"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="feature-text">
|
||||
<h3>Project workspaces</h3>
|
||||
<p>
|
||||
Per-project chat with the agent's working directory pinned, custom dashboards rendered
|
||||
from a JSON spec the agent itself can author, project-scoped slash commands defined as
|
||||
Markdown files, and a Scarf-managed <code>AGENTS.md</code> block that gives Hermes
|
||||
project context before every session boots. Templates package these into a single
|
||||
<code>.scarftemplate</code> bundle you can share or install with one click.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-projects-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-projects-dark.png" src="assets/screenshots/mac-projects.png"
|
||||
alt="Mac app — project dashboard with stat boxes and a chart widget"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature feature-flip">
|
||||
<div class="feature-text">
|
||||
<h3>Multi-server over SSH</h3>
|
||||
<p>
|
||||
One window per Hermes server. Local <code>~/.hermes/</code> is synthesized
|
||||
automatically; remote servers connect through your existing
|
||||
<code>~/.ssh/config</code>, ssh-agent, ProxyJump, and ControlMaster. File I/O routes
|
||||
through scp/sftp; the SQLite database is served from atomic snapshots; chat tunnels as
|
||||
<code>ssh -T host hermes acp</code> with JSON-RPC end-to-end. There is no companion
|
||||
service in the middle.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-sessions-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-sessions-dark.png" src="assets/screenshots/mac-sessions.png"
|
||||
alt="Mac app — sessions browser filtered to a remote server"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="feature-text">
|
||||
<h3>Memory, skills, MCP</h3>
|
||||
<p>
|
||||
Edit <code>MEMORY.md</code> and <code>USER.md</code> with live file-watcher refresh and
|
||||
external-provider awareness. Browse the Skills Hub across six registries, install,
|
||||
update, and inspect <code>SKILL.md</code> frontmatter. Configure MCP servers from a
|
||||
curated preset list (GitHub, Linear, Notion, Sentry, Stripe) or fully custom; test the
|
||||
connection in-app and watch the discovered tool list populate.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-mcp-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-mcp-dark.png" src="assets/screenshots/mac-mcp.png"
|
||||
alt="Mac app — MCP servers configuration view"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature feature-flip">
|
||||
<div class="feature-text">
|
||||
<h3>Cron & messaging gateways</h3>
|
||||
<p>
|
||||
Create, edit, pause, resume, run-now, and delete cron jobs without touching the CLI —
|
||||
with human-readable schedules ("Every weekday at 9:00 AM") and the underlying
|
||||
expression a tap away. Native GUI for thirteen messaging platforms (Telegram, Discord,
|
||||
Slack, WhatsApp, Signal, iMessage, Email, Matrix, Mattermost, Feishu, Home Assistant,
|
||||
Webhook, CLI) with per-platform credential forms and connectivity dots.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-cron-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-cron-dark.png" src="assets/screenshots/mac-cron.png"
|
||||
alt="Mac app — cron manager with paused and active jobs"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="feature-text">
|
||||
<h3>Insights & health</h3>
|
||||
<p>
|
||||
Token usage and cost broken down by day, model, and platform; reasoning tokens
|
||||
tracked separately. Activity heatmaps and notable sessions across 7, 30, 90 days, or
|
||||
all time. Component-level health checks, on-demand diagnostics, and a one-click debug
|
||||
report uploader for Hermes support — with a confirmation dialog before anything
|
||||
leaves the machine.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-visual">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/screenshots/mac-dashboard-dark.png">
|
||||
<img data-dark-src="assets/screenshots/mac-dashboard-dark.png" src="assets/screenshots/mac-dashboard.png"
|
||||
alt="Mac app — dashboard with token usage, recent sessions, and health"
|
||||
width="1600" height="1000" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- iOS / ScarfGo -->
|
||||
<section class="ios" id="ios" aria-labelledby="ios-title">
|
||||
<div class="ios-copy">
|
||||
<p class="eyebrow">ScarfGo for iPhone</p>
|
||||
<h2 id="ios-title">Your Hermes agent, on your phone.</h2>
|
||||
<p>
|
||||
ScarfGo is the native iOS companion to Scarf. It speaks SSH directly to your Hermes
|
||||
host using <a href="https://github.com/orlandos-nl/Citadel" rel="noopener">Citadel</a> — a
|
||||
pure-Swift SSH stack, no companion service, no developer-controlled relay. Generate an
|
||||
Ed25519 keypair on the device, paste the public half into <code>authorized_keys</code>,
|
||||
and you have full project-aware chat, session resume, memory editing, cron browsing,
|
||||
and skill management on iOS 18+.
|
||||
</p>
|
||||
<ul class="ios-points">
|
||||
<li>Pure-Swift SSH — keys live in the iOS Keychain and never leave the device.</li>
|
||||
<li>Multi-server: connect to as many Hermes hosts as you have SSH access to.</li>
|
||||
<li>Project-scoped chat writes the same Scarf-managed <code>AGENTS.md</code> block as Mac.</li>
|
||||
<li>Same session attribution, so "which project does this conversation belong to?" matches across devices.</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="https://testflight.apple.com/join/qCrRpcTz" rel="noopener">
|
||||
<span>Join the public TestFlight</span>
|
||||
<span class="btn-meta">iOS 18+</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="ios-gallery" role="list" aria-label="ScarfGo screenshots">
|
||||
<div class="phone-frame" role="listitem">
|
||||
<img src="assets/screenshots/ios-servers.png" alt="ScarfGo — server picker with two configured Hermes hosts" loading="lazy" decoding="async" width="430" height="932">
|
||||
</div>
|
||||
<div class="phone-frame" role="listitem">
|
||||
<img src="assets/screenshots/ios-chat.png" alt="ScarfGo — chat with the Hermes agent" loading="lazy" decoding="async" width="430" height="932">
|
||||
</div>
|
||||
<div class="phone-frame" role="listitem">
|
||||
<img src="assets/screenshots/ios-project-dashboard.png" alt="ScarfGo — project dashboard" loading="lazy" decoding="async" width="430" height="932">
|
||||
</div>
|
||||
<div class="phone-frame" role="listitem">
|
||||
<img src="assets/screenshots/ios-skills.png" alt="ScarfGo — skills browser" loading="lazy" decoding="async" width="430" height="932">
|
||||
</div>
|
||||
<div class="phone-frame" role="listitem">
|
||||
<img src="assets/screenshots/ios-system.png" alt="ScarfGo — system tab" loading="lazy" decoding="async" width="430" height="932">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why native -->
|
||||
<section class="why" id="why" aria-labelledby="why-title">
|
||||
<h2 id="why-title" class="section-heading">Why a native app</h2>
|
||||
<div class="why-grid">
|
||||
<article class="why-card">
|
||||
<h3>Native, not Electron</h3>
|
||||
<p>
|
||||
One Mach-O binary. SwiftUI on macOS 14.6+ and iOS 18+. Kilobytes of memory and
|
||||
negligible energy use compared to a bundled Chromium. Drag-and-drop, sharing, the
|
||||
menu bar, Spotlight, and accessibility all behave the way a Mac or iPhone user
|
||||
expects.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<h3>Read-only safe</h3>
|
||||
<p>
|
||||
Scarf opens <code>~/.hermes/state.db</code> in read-only WAL mode. The app cannot
|
||||
corrupt your Hermes data even if it crashes mid-write — because it never writes.
|
||||
Memory files and cron jobs are the only mutable surfaces, both with explicit
|
||||
confirmations.
|
||||
</p>
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<h3>Open and inspectable</h3>
|
||||
<p>
|
||||
<a href="https://github.com/awizemann/scarf" rel="noopener">MIT licensed</a>, pure
|
||||
Swift, zero external runtime dependencies. Build it yourself with
|
||||
<code>xcodebuild</code> in two minutes. Sparkle auto-updates ship signed,
|
||||
notarized, EdDSA-verified zips — never silent over-the-wire mutations.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Templates teaser -->
|
||||
<section class="templates" id="templates" aria-labelledby="templates-title">
|
||||
<h2 id="templates-title">Project templates</h2>
|
||||
<p>
|
||||
A <code>.scarftemplate</code> bundle packages a project's dashboard, skills, cron jobs,
|
||||
memory blocks, slash commands, and configuration schema into one shareable file.
|
||||
Browse the public catalog and install with a single click.
|
||||
</p>
|
||||
<a class="btn btn-secondary" href="templates/">Browse the template catalog →</a>
|
||||
</section>
|
||||
|
||||
<!-- Download -->
|
||||
<section class="download" id="download" aria-labelledby="download-title">
|
||||
<h2 id="download-title" class="section-heading">Download</h2>
|
||||
<div class="download-grid">
|
||||
<article class="download-card">
|
||||
<h3>Scarf for Mac</h3>
|
||||
<p class="download-meta">macOS 14.6 (Sonoma) or later · Apple Silicon & Intel · Universal binary</p>
|
||||
<ul class="download-points">
|
||||
<li>Notarized, code-signed Developer ID build</li>
|
||||
<li>Sparkle auto-updates with EdDSA signature verification</li>
|
||||
<li>Free and open source under the MIT license</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="https://github.com/awizemann/scarf/releases/latest" rel="noopener">
|
||||
<span>Get the latest release</span>
|
||||
<span class="btn-meta">GitHub Releases · .zip</span>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<article class="download-card">
|
||||
<h3>ScarfGo for iPhone</h3>
|
||||
<p class="download-meta">iOS 18.0 or later · iPhone · public TestFlight</p>
|
||||
<ul class="download-points">
|
||||
<li>Pure-Swift SSH — no companion service required</li>
|
||||
<li>Multi-server support, identical to Mac</li>
|
||||
<li>Free and open source under the MIT license</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="https://testflight.apple.com/join/qCrRpcTz" rel="noopener">
|
||||
<span>Join the TestFlight</span>
|
||||
<span class="btn-meta">Apple TestFlight</span>
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="download-prereq">
|
||||
Both apps require <a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes</a>
|
||||
installed at <code>~/.hermes/</code> on each host you want to manage. Scarf does not include
|
||||
Hermes itself — see the
|
||||
<a href="https://github.com/hermes-ai/hermes-agent#installation" rel="noopener">Hermes installation
|
||||
guide</a> first.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section class="faq" id="faq" aria-labelledby="faq-title">
|
||||
<h2 id="faq-title" class="section-heading">Frequently asked questions</h2>
|
||||
<div class="faq-list">
|
||||
|
||||
<details>
|
||||
<summary>What is Scarf?</summary>
|
||||
<div>
|
||||
<p>Scarf is a native macOS and iOS GUI for the <a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes AI agent</a>. It surfaces Hermes's sessions, projects, memory, skills, MCP servers, cron jobs, messaging gateways, logs, and configuration through a sidebar-driven Mac app and a tab-based iPhone companion called ScarfGo.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Do I need Hermes installed first?</summary>
|
||||
<div>
|
||||
<p>Yes. Scarf is a client for an existing Hermes installation. It expects to find Hermes's data directory at <code>~/.hermes/</code> on each host you connect to (local or remote). Install Hermes first by following the <a href="https://github.com/hermes-ai/hermes-agent#installation" rel="noopener">Hermes installation guide</a>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Does Scarf work without internet?</summary>
|
||||
<div>
|
||||
<p>Yes for the local Hermes case — Scarf reads files and the SQLite database directly from <code>~/.hermes/</code> with no network involvement. Internet is only required when Hermes itself reaches out to model providers or MCP servers, when you connect to a remote Hermes host over SSH, or when checking for Sparkle updates.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Is Scarf open source?</summary>
|
||||
<div>
|
||||
<p>Yes. Both Scarf and ScarfGo are <a href="https://github.com/awizemann/scarf/blob/main/LICENSE" rel="noopener">MIT licensed</a> and built from the same open repository at <a href="https://github.com/awizemann/scarf" rel="noopener">github.com/awizemann/scarf</a>. There are no closed-source components and no telemetry.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What macOS and iOS versions are supported?</summary>
|
||||
<div>
|
||||
<p>Scarf for Mac requires macOS 14.6 Sonoma or later, on Apple Silicon or Intel. ScarfGo for iPhone requires iOS 18.0 or later. Both are universal builds; there is no separate Apple Silicon download.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How does ScarfGo connect to my Mac?</summary>
|
||||
<div>
|
||||
<p>ScarfGo speaks SSH directly to your Hermes host using a pure-Swift SSH stack (Citadel). On first launch it generates an Ed25519 keypair on the device — the private key lives in the iOS Keychain (with <code>kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly</code>) and is excluded from iCloud sync. Paste the public key into the host's <code>~/.ssh/authorized_keys</code>, and ScarfGo can run <code>hermes acp</code> over the SSH session for chat and read the SQLite database for everything else. There is no companion service or developer-controlled relay.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What data does Scarf collect?</summary>
|
||||
<div>
|
||||
<p>None. Scarf has no telemetry, no analytics, no crash reporter, and no account system. The only outbound network connections are to GitHub Releases (when you check for updates via Sparkle), to remote Hermes hosts you explicitly add, and to Hermes's own model providers and MCP servers — all initiated by Hermes, not Scarf.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Where are my conversations stored?</summary>
|
||||
<div>
|
||||
<p>In Hermes's own data directory — <code>~/.hermes/state.db</code> for session history and <code>~/.hermes/sessions/session_*.json</code> for full transcripts. Scarf reads these files but never writes to <code>state.db</code>. ScarfGo reads them through SSH-initiated SQLite snapshots and never caches them locally on the device.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How do updates work?</summary>
|
||||
<div>
|
||||
<p>Scarf for Mac uses <a href="https://sparkle-project.org/" rel="noopener">Sparkle</a> for in-app updates — signed and notarized zips with EdDSA signature verification. The appcast lives at <a href="appcast.xml">awizemann.github.io/scarf/appcast.xml</a>. ScarfGo updates through TestFlight in the usual way until it ships on the App Store.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Can I use Scarf with a remote or headless Hermes server?</summary>
|
||||
<div>
|
||||
<p>Yes — that is one of the main use cases. Add the host through <strong>File → Open Server… → Add Server</strong> on Mac, or tap <strong>Add Server</strong> on the ScarfGo Servers tab. Scarf uses the system SSH config (Mac) or a device-generated key (iOS), so anything reachable through your normal terminal SSH workflow works without extra setup. The remote host needs <code>sqlite3</code> and <code>pgrep</code> on its <code>$PATH</code> and the SSH user needs read access to <code>~/.hermes/</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's the difference between Scarf and using Hermes from the terminal?</summary>
|
||||
<div>
|
||||
<p>Scarf is strictly additive — it visualizes data Hermes already produces. The terminal CLI (<code>hermes chat</code>, <code>hermes cron</code>, <code>hermes mcp</code>, etc.) remains the source of truth for everything. Scarf gives you live streaming chat with rich tool-call rendering, multi-server windows, project workspaces with custom dashboards, and a one-pane view of skills, MCP servers, cron jobs, and gateways without memorizing CLI subcommands.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Is there a Windows or Linux version?</summary>
|
||||
<div>
|
||||
<p>No. Scarf is built on SwiftUI and AppKit and ships only for Apple platforms. There are no current plans for Windows or Linux ports — the
|
||||
<a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes CLI</a> itself works on those platforms, and Scarf can connect to a remote Linux Hermes host from a Mac.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="site-footer" role="contentinfo">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img src="assets/scarf-icon-512.png" width="40" height="40" alt="" decoding="async">
|
||||
<p>Scarf is made by <a href="https://github.com/awizemann" rel="noopener">Alan Wizemann</a>. MIT licensed.</p>
|
||||
</div>
|
||||
<nav class="footer-nav" aria-label="Footer">
|
||||
<div>
|
||||
<h4>Project</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com/awizemann/scarf" rel="noopener">GitHub</a></li>
|
||||
<li><a href="https://github.com/awizemann/scarf/wiki" rel="noopener">Wiki</a></li>
|
||||
<li><a href="https://github.com/awizemann/scarf/releases" rel="noopener">Releases</a></li>
|
||||
<li><a href="https://github.com/awizemann/scarf/blob/main/LICENSE" rel="noopener">License (MIT)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Community</h4>
|
||||
<ul>
|
||||
<li><a href="templates/">Template catalog</a></li>
|
||||
<li><a href="https://github.com/awizemann/scarf/discussions" rel="noopener">Discussions</a></li>
|
||||
<li><a href="https://github.com/awizemann/scarf/issues" rel="noopener">Report a bug</a></li>
|
||||
<li><a href="https://www.buymeacoffee.com/awizemann" rel="noopener">Buy me a coffee</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Technical</h4>
|
||||
<ul>
|
||||
<li><a href="appcast.xml">Sparkle appcast</a></li>
|
||||
<li><a href="llms.txt">llms.txt</a></li>
|
||||
<li><a href="https://github.com/hermes-ai/hermes-agent" rel="noopener">Hermes agent</a></li>
|
||||
<li><a href="https://testflight.apple.com/join/qCrRpcTz" rel="noopener">TestFlight (iOS)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Structured data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Scarf",
|
||||
"alternateName": "Scarf for Mac",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "macOS 14.6 or later",
|
||||
"description": "Native macOS GUI for the Hermes AI agent. Sessions, projects, memory, skills, MCP servers, cron jobs, messaging gateways, multi-server SSH.",
|
||||
"url": "https://awizemann.github.io/scarf/",
|
||||
"downloadUrl": "https://github.com/awizemann/scarf/releases/latest",
|
||||
"softwareVersion": "2.5.2",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"image": "https://awizemann.github.io/scarf/assets/og-image.png",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "MobileApplication",
|
||||
"name": "ScarfGo",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "iOS 18.0 or later",
|
||||
"description": "Native iPhone companion to Scarf. Multi-server Hermes management over SSH, project-aware chat, memory editor, cron browser, skills browser.",
|
||||
"url": "https://awizemann.github.io/scarf/#ios",
|
||||
"downloadUrl": "https://testflight.apple.com/join/qCrRpcTz",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is Scarf?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Scarf is a native macOS and iOS GUI for the Hermes AI agent. It surfaces Hermes's sessions, projects, memory, skills, MCP servers, cron jobs, messaging gateways, logs, and configuration through a sidebar-driven Mac app and a tab-based iPhone companion called ScarfGo."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Do I need Hermes installed first?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. Scarf is a client for an existing Hermes installation. It expects to find Hermes's data directory at ~/.hermes/ on each host you connect to. Install Hermes first by following the Hermes installation guide."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Does Scarf work without internet?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes for the local Hermes case — Scarf reads files and the SQLite database directly from ~/.hermes/ with no network involvement. Internet is only required when Hermes itself reaches out to model providers or MCP servers, when connecting to a remote Hermes host over SSH, or when checking for Sparkle updates."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is Scarf open source?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. Both Scarf and ScarfGo are MIT licensed and built from the same open repository at github.com/awizemann/scarf. There are no closed-source components and no telemetry."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What macOS and iOS versions are supported?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Scarf for Mac requires macOS 14.6 Sonoma or later, on Apple Silicon or Intel. ScarfGo for iPhone requires iOS 18.0 or later. Both are universal builds."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How does ScarfGo connect to my Mac?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "ScarfGo speaks SSH directly to your Hermes host using Citadel, a pure-Swift SSH stack. On first launch it generates an Ed25519 keypair on the device — the private key lives in the iOS Keychain and never leaves the phone. Paste the public key into the host's authorized_keys and ScarfGo can run hermes acp over the SSH session for chat and read the SQLite database for everything else. There is no companion service or relay."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What data does Scarf collect?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "None. Scarf has no telemetry, no analytics, no crash reporter, and no account system. The only outbound network connections are to GitHub Releases (Sparkle update checks), remote Hermes hosts you explicitly add, and Hermes's own model providers and MCP servers — all initiated by Hermes, not Scarf."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Where are my conversations stored?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "In Hermes's own data directory — ~/.hermes/state.db for session history and ~/.hermes/sessions/session_*.json for full transcripts. Scarf reads these files but never writes to state.db. ScarfGo reads them through SSH-initiated SQLite snapshots and never caches them locally on the device."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How do updates work?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Scarf for Mac uses Sparkle for in-app updates — signed and notarized zips with EdDSA signature verification. The appcast lives at awizemann.github.io/scarf/appcast.xml. ScarfGo updates through TestFlight in the usual way until it ships on the App Store."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Can I use Scarf with a remote or headless Hermes server?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Yes. Add the host through File → Open Server… → Add Server on Mac, or Add Server on ScarfGo. Scarf uses the system SSH config (Mac) or a device-generated key (iOS), so anything reachable through normal terminal SSH works without extra setup. The remote host needs sqlite3 and pgrep on its PATH and the SSH user needs read access to ~/.hermes/."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What's the difference between Scarf and using Hermes from the terminal?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Scarf is strictly additive — it visualizes data Hermes already produces. The terminal CLI remains the source of truth. Scarf gives you live streaming chat with rich tool-call rendering, multi-server windows, project workspaces with custom dashboards, and a one-pane view of skills, MCP servers, cron jobs, and gateways without memorizing CLI subcommands."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is there a Windows or Linux version?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No. Scarf is built on SwiftUI and AppKit and ships only for Apple platforms. The Hermes CLI itself works on those platforms, and Scarf can connect to a remote Linux Hermes host from a Mac."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
# Scarf
|
||||
|
||||
> Scarf is a native macOS and iOS GUI for the Hermes AI agent. It surfaces every part of a running Hermes installation — chat sessions, project workspaces, memory files, installed skills, MCP servers, cron jobs, messaging gateways, logs, and configuration — through a sidebar-driven Mac app and a tab-based iPhone companion called ScarfGo.
|
||||
|
||||
Both apps are free, MIT licensed, and built from a single open repository. Scarf reads from `~/.hermes/state.db` directly (read-only) and streams agent replies in real time over the Agent Client Protocol. It connects to remote Hermes installations using the host's existing SSH config — no companion service, no telemetry, no account.
|
||||
|
||||
## Quick facts
|
||||
|
||||
- **Platforms:** macOS 14.6+ Sonoma (Apple Silicon and Intel), iOS 18+
|
||||
- **License:** MIT
|
||||
- **Repository:** https://github.com/awizemann/scarf
|
||||
- **Author:** Alan Wizemann
|
||||
- **Hermes prerequisite:** https://github.com/hermes-ai/hermes-agent installed at `~/.hermes/`
|
||||
- **Mac download:** https://github.com/awizemann/scarf/releases/latest
|
||||
- **iOS download:** https://testflight.apple.com/join/qCrRpcTz (public TestFlight)
|
||||
- **Auto-updates (Mac):** Sparkle, with EdDSA signature verification
|
||||
- **Telemetry:** none
|
||||
|
||||
## Documentation
|
||||
|
||||
- [README](https://github.com/awizemann/scarf/blob/main/README.md): project overview, full feature list, build instructions
|
||||
- [Wiki](https://github.com/awizemann/scarf/wiki): user guide, architecture, design system reference
|
||||
- [Wiki — ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo): iOS companion details
|
||||
- [Wiki — ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding): SSH key setup walkthrough
|
||||
- [Wiki — Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences): what is and isn't shared between Mac and iOS
|
||||
- [Releases](https://github.com/awizemann/scarf/releases): release notes for every version
|
||||
- [License](https://github.com/awizemann/scarf/blob/main/LICENSE): MIT
|
||||
|
||||
## Feature surfaces
|
||||
|
||||
Mac (sidebar sections):
|
||||
|
||||
- **Monitor:** Dashboard, Insights, Sessions Browser, Activity Feed
|
||||
- **Interact:** Live Chat (ACP streaming + Terminal mode), Memory Viewer/Editor, Skills Browser
|
||||
- **Configure:** Platforms, Personalities, Quick Commands, Credential Pools, Plugins, Webhooks, Profiles
|
||||
- **Manage:** Tools, MCP Servers, Gateway Control, Cron Manager, Health, Log Viewer, Settings
|
||||
- **Project Dashboards:** custom JSON-defined dashboards rendered by Scarf, populated by the agent
|
||||
- **System:** Hermes process control, menu bar status
|
||||
|
||||
iOS (ScarfGo, tabs):
|
||||
|
||||
- Servers (multi-host management with pure-Swift SSH)
|
||||
- Dashboard (stats + recent sessions per server)
|
||||
- Chat (full ACP, project-scoped)
|
||||
- Sessions (resume, attribute to projects)
|
||||
- Memory editor (read/write `MEMORY.md`, `USER.md`)
|
||||
- Cron (list view, human-readable schedules)
|
||||
- Skills browser (categories + prereq banners)
|
||||
- Settings (read-only `config.yaml`)
|
||||
|
||||
## Differentiators
|
||||
|
||||
- Native SwiftUI, not Electron — single Mach-O binary, kilobytes of memory, full system integration
|
||||
- Read-only access to `state.db` — Scarf cannot corrupt Hermes data because it never writes
|
||||
- Multi-server: one window per Hermes host on Mac, multi-server on iOS, all over standard SSH
|
||||
- Project-scoped chat with Scarf-managed `AGENTS.md` block injected before session boot
|
||||
- Portable `.scarftemplate` bundles for sharing project setups (dashboards, skills, cron jobs, slash commands)
|
||||
- Live ACP streaming with rich tool-call rendering, permission dialogs, voice control
|
||||
- 13 messaging platforms managed in one native UI (Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Email, Matrix, Mattermost, Feishu, Home Assistant, Webhook, CLI)
|
||||
- Open and inspectable — pure Swift, MIT, no external runtime dependencies
|
||||
|
||||
## Optional
|
||||
|
||||
- [Templates Catalog](https://awizemann.github.io/scarf/templates/): community-contributed `.scarftemplate` bundles, one-click install
|
||||
- [Sparkle Appcast](https://awizemann.github.io/scarf/appcast.xml): the auto-update feed (RSS/XML)
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "Scarf",
|
||||
"short_name": "Scarf",
|
||||
"description": "Native macOS and iOS GUI for the Hermes AI agent.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "browser",
|
||||
"background_color": "#FAF7F2",
|
||||
"theme_color": "#C2563D",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "assets/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
## What's New in 1.6.1
|
||||
|
||||
### Auto-updates
|
||||
|
||||
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
|
||||
|
||||
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
|
||||
|
||||
### Notarized & Developer ID signed
|
||||
|
||||
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
|
||||
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
|
||||
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
|
||||
|
||||
---
|
||||
|
||||
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
|
||||
@@ -0,0 +1,13 @@
|
||||
## What's New in 1.6.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
|
||||
|
||||
### Polish
|
||||
|
||||
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
|
||||
|
||||
---
|
||||
|
||||
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
|
||||
@@ -0,0 +1,58 @@
|
||||
## What's New in 2.0
|
||||
|
||||
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
|
||||
|
||||
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
|
||||
|
||||
### Multi-server
|
||||
|
||||
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
|
||||
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
|
||||
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
|
||||
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
|
||||
|
||||
### Remote over SSH
|
||||
|
||||
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
|
||||
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
|
||||
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
|
||||
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
|
||||
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
|
||||
|
||||
### Chat UX overhaul
|
||||
|
||||
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
|
||||
|
||||
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
|
||||
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
|
||||
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
|
||||
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
|
||||
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
|
||||
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
|
||||
|
||||
### Correctness — remote SQLite
|
||||
|
||||
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
|
||||
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()` — `LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
|
||||
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
|
||||
- ACP cwd now comes from the *remote* `$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
|
||||
|
||||
### Compatibility
|
||||
|
||||
Hermes v0.10.0 is now verified alongside v0.6–v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
|
||||
|
||||
### Migration from 1.6.x
|
||||
|
||||
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
|
||||
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
|
||||
- `servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
|
||||
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
|
||||
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://awizemann.github.io/scarf/sitemap.xml
|
||||
@@ -0,0 +1,111 @@
|
||||
# Scarf — Architecture
|
||||
|
||||
## Pattern: MVVM-Feature
|
||||
|
||||
Per project standards, every feature is a self-contained module owning Models, ViewModels, and Views.
|
||||
|
||||
```
|
||||
scarf/
|
||||
Core/
|
||||
Services/ Hermes data access (SQLite, file I/O, ACP)
|
||||
Models/ Plain data structs for Hermes entities
|
||||
Features/
|
||||
Dashboard/
|
||||
Views/ DashboardView
|
||||
ViewModels/ DashboardViewModel
|
||||
Sessions/
|
||||
Views/ SessionsView, SessionDetailView
|
||||
ViewModels/ SessionsViewModel
|
||||
Activity/
|
||||
Views/ ActivityView
|
||||
ViewModels/ ActivityViewModel
|
||||
Chat/
|
||||
Views/ ChatView
|
||||
ViewModels/ ChatViewModel
|
||||
Memory/
|
||||
Views/ MemoryView
|
||||
ViewModels/ MemoryViewModel
|
||||
Skills/
|
||||
Views/ SkillsView
|
||||
ViewModels/ SkillsViewModel
|
||||
Cron/
|
||||
Views/ CronView
|
||||
ViewModels/ CronViewModel
|
||||
Logs/
|
||||
Views/ LogsView
|
||||
ViewModels/ LogsViewModel
|
||||
Settings/
|
||||
Views/ SettingsView
|
||||
ViewModels/ SettingsViewModel
|
||||
Navigation/
|
||||
AppCoordinator.swift
|
||||
SidebarView.swift
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
`AppCoordinator` is `@Observable` and injected via `.environment()` at the app root. It owns:
|
||||
- `selectedSection: SidebarSection` — which feature is active
|
||||
- `selectedSessionID: String?` — drill-down into a session
|
||||
|
||||
One `NavigationSplitView` at top level, driven by the coordinator. Leaf views read but never own navigation state.
|
||||
|
||||
## Services
|
||||
|
||||
### HermesDataService
|
||||
- Opens `~/.hermes/state.db` read-only via SQLite3 C API
|
||||
- Queries `sessions` and `messages` tables
|
||||
- Provides session list, message history, search (FTS5), and aggregate stats
|
||||
- Polling-based refresh (watches WAL modification time)
|
||||
|
||||
### HermesFileService
|
||||
- Reads config.yaml (simple line parser for the YAML subset we need)
|
||||
- Reads/writes memory markdown files
|
||||
- Reads cron jobs.json, gateway_state.json, session JSON files
|
||||
- Reads skill directory structure
|
||||
|
||||
### HermesLogService
|
||||
- Tails log files using file handle + periodic polling
|
||||
- Parses log level from line format
|
||||
|
||||
### ACPClient
|
||||
- Spawns `hermes acp` via Foundation `Process`
|
||||
- Writes JSON-RPC to stdin, reads from stdout
|
||||
- Streams events: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought
|
||||
- Manages session lifecycle
|
||||
|
||||
### HermesFileWatcher
|
||||
- Uses `DispatchSource.makeFileSystemObjectSource` on key directories
|
||||
- Triggers refresh callbacks when Hermes writes new data
|
||||
|
||||
## Dependencies
|
||||
|
||||
Zero external SPM packages:
|
||||
- **SQLite**: System `sqlite3` C library (available on macOS, `import SQLite3` not needed — use `libsqlite3`)
|
||||
- **JSON**: Foundation `JSONDecoder` / `JSONSerialization`
|
||||
- **YAML**: Custom lightweight parser for flat config structure
|
||||
- **Markdown**: `AttributedString(markdown:)` (built into Foundation)
|
||||
- **File watching**: GCD `DispatchSource`
|
||||
- **Subprocess**: Foundation `Process` + `Pipe`
|
||||
|
||||
## Sandbox
|
||||
|
||||
Disabled. This app reads directly from `~/.hermes/` which is outside any app sandbox container. The `ENABLE_APP_SANDBOX` build setting is set to `NO`.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Swift 6 strict concurrency with `@MainActor` default isolation
|
||||
- Services use `nonisolated` methods with async/await for I/O
|
||||
- `@Observable` ViewModels on MainActor, call into nonisolated services
|
||||
- ACP client runs its read loop on a background task
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
~/.hermes/state.db ──→ HermesDataService ──→ ViewModels ──→ Views
|
||||
~/.hermes/config.yaml ──→ HermesFileService ──→ ViewModels ──→ Views
|
||||
~/.hermes/memories/ ──→ HermesFileService ──→ ViewModels ──→ Views
|
||||
~/.hermes/logs/ ──→ HermesLogService ──→ ViewModels ──→ Views
|
||||
hermes acp (subprocess) ──→ ACPClient ──→ ChatViewModel ──→ ChatView
|
||||
HermesFileWatcher ──→ triggers refresh on all services
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
# Scarf Project Dashboard Schema
|
||||
|
||||
Scarf can render project dashboards from a JSON file. Place a `dashboard.json` file at `.scarf/dashboard.json` in your project root, and register the project in Scarf.
|
||||
|
||||
## Registration
|
||||
|
||||
Projects are registered in `~/.hermes/scarf/projects.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "my-project", "path": "/path/to/my-project" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can also add projects from the Scarf UI via the Projects section.
|
||||
|
||||
## Dashboard File
|
||||
|
||||
Create `.scarf/dashboard.json` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "My Project",
|
||||
"description": "Optional description",
|
||||
"updatedAt": "2026-03-31T14:00:00Z",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Section Name",
|
||||
"columns": 3,
|
||||
"widgets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Widget Types
|
||||
|
||||
### stat — Key metric display
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Test Coverage",
|
||||
"value": "87.3%",
|
||||
"icon": "checkmark.shield",
|
||||
"color": "green",
|
||||
"subtitle": "+2.1% from last week"
|
||||
}
|
||||
```
|
||||
|
||||
- `value`: String or number
|
||||
- `icon`: SF Symbol name (optional)
|
||||
- `color`: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray (optional)
|
||||
- `subtitle`: Secondary text (optional)
|
||||
|
||||
### progress — Progress bar
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "progress",
|
||||
"title": "Sprint Progress",
|
||||
"value": 0.73,
|
||||
"label": "73% complete",
|
||||
"color": "blue"
|
||||
}
|
||||
```
|
||||
|
||||
- `value`: Number between 0.0 and 1.0
|
||||
- `label`: Text below the bar (optional)
|
||||
- `color`: Named color (optional)
|
||||
|
||||
### text — Rich text block
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"title": "Release Notes",
|
||||
"content": "**v2.4.1** — Fixed auth timeout\n\n- Bug fix for session handling",
|
||||
"format": "markdown"
|
||||
}
|
||||
```
|
||||
|
||||
- `content`: Text content
|
||||
- `format`: "markdown" or "plain" (default: plain)
|
||||
|
||||
### table — Data table
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "table",
|
||||
"title": "Recent Deploys",
|
||||
"columns": ["Date", "Env", "Status"],
|
||||
"rows": [
|
||||
["Mar 30", "prod", "success"],
|
||||
["Mar 29", "staging", "success"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### chart — Line, bar, or pie chart
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "chart",
|
||||
"title": "Tests Over Time",
|
||||
"chartType": "line",
|
||||
"series": [
|
||||
{
|
||||
"name": "Passing",
|
||||
"color": "green",
|
||||
"data": [
|
||||
{ "x": "Mon", "y": 142 },
|
||||
{ "x": "Tue", "y": 145 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `chartType`: "line", "bar", or "pie"
|
||||
- `series[].color`: Named color (optional)
|
||||
- For pie charts, each series becomes a slice
|
||||
|
||||
### list — Checklist
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "list",
|
||||
"title": "TODO Items",
|
||||
"icon": "checklist",
|
||||
"items": [
|
||||
{ "text": "Write tests", "status": "done" },
|
||||
{ "text": "Update docs", "status": "active" },
|
||||
{ "text": "Deploy", "status": "pending" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
|
||||
|
||||
### webview — Embedded web browser
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "webview",
|
||||
"title": "Project Dashboard",
|
||||
"url": "http://localhost:8000",
|
||||
"height": 500
|
||||
}
|
||||
```
|
||||
|
||||
- `url`: Any URL — local servers, file paths, or remote pages
|
||||
- `height`: Height in points (optional, default: 400)
|
||||
|
||||
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
To have your Hermes agent generate a dashboard, include these instructions:
|
||||
|
||||
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
|
||||
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
|
||||
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
|
||||
> in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
|
||||
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Hermes Agent — Discovery Notes
|
||||
|
||||
## Installation
|
||||
|
||||
- Binary: `~/.local/bin/hermes` (symlink to venv wrapper)
|
||||
- Codebase: `~/.hermes/hermes-agent/` (Python 3.11 venv)
|
||||
- Version: v0.6.0 (March 30, 2026)
|
||||
- Runs as daemon process
|
||||
|
||||
## What Hermes Does
|
||||
|
||||
A self-improving AI agent with tool-calling capabilities:
|
||||
- Interactive terminal chat with syntax highlighting
|
||||
- 40+ tools (terminal, file, browser, web, code execution, vision, etc.)
|
||||
- Autonomous skill creation from complex tasks
|
||||
- Persistent memory (MEMORY.md + USER.md) with periodic nudges
|
||||
- Multi-platform messaging gateway (Telegram, Discord, Slack, WhatsApp, Signal, Email)
|
||||
- Cron scheduler for recurring tasks
|
||||
- Session persistence in SQLite with FTS5 search
|
||||
- Subagent delegation for parallel workstreams
|
||||
- MCP (Model Context Protocol) integration
|
||||
- ACP (Agent Client Protocol) for IDE integration
|
||||
|
||||
## File System Layout
|
||||
|
||||
```
|
||||
~/.hermes/
|
||||
hermes-agent/ Python codebase (70 directories)
|
||||
run_agent.py Core agent loop
|
||||
cli.py Terminal UI
|
||||
model_tools.py Tool dispatcher
|
||||
toolsets.py Tool definitions
|
||||
agent/ Agent internals
|
||||
tools/ 40+ tool implementations
|
||||
gateway/ Multi-platform messaging
|
||||
cron/ Scheduler implementation
|
||||
hermes_cli/ CLI command handlers
|
||||
acp_adapter/ Agent Client Protocol server
|
||||
venv/ Python environment
|
||||
config.yaml User configuration (8.8 KB)
|
||||
.env API keys (encrypted)
|
||||
auth.json OAuth tokens
|
||||
state.db SQLite session database (WAL mode)
|
||||
sessions/ JSON conversation snapshots
|
||||
memories/ MEMORY.md, USER.md
|
||||
skills/ 29 installed skills across 15+ categories
|
||||
cron/
|
||||
jobs.json Scheduled job definitions
|
||||
output/ Job execution output
|
||||
logs/
|
||||
errors.log Application errors
|
||||
gateway.log Gateway platform logs
|
||||
gateway_state.json Gateway process lifecycle
|
||||
```
|
||||
|
||||
## SQLite Schema (state.db, version 6)
|
||||
|
||||
### sessions table
|
||||
```sql
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT, -- 'cli', 'telegram', 'discord', etc.
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT, -- JSON
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT, -- Session splitting on compression
|
||||
started_at REAL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER,
|
||||
tool_call_count INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT UNIQUE
|
||||
```
|
||||
|
||||
### messages table
|
||||
```sql
|
||||
id INTEGER PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT, -- 'user' or 'assistant'
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT, -- JSON array of tool invocations
|
||||
tool_name TEXT,
|
||||
timestamp REAL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT
|
||||
```
|
||||
|
||||
### messages_fts (FTS5 virtual table)
|
||||
Full-text search on message content.
|
||||
|
||||
## Session JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "YYYYmmdd_HHMMSS_6hexchars",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"platform": "cli",
|
||||
"session_start": "ISO8601",
|
||||
"last_updated": "ISO8601",
|
||||
"system_prompt": "...",
|
||||
"tools": [{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "..."},
|
||||
{"role": "assistant", "content": "...", "tool_calls": [
|
||||
{"id": "call_...", "type": "function", "function": {"name": "terminal", "arguments": "{\"command\": \"...\"}"}}
|
||||
]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Jobs Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [{
|
||||
"id": "12hexchars",
|
||||
"name": "Job Name",
|
||||
"prompt": "What to do",
|
||||
"skills": ["skill-name"],
|
||||
"schedule": {"kind": "once|cron", "run_at": "ISO8601", "display": "human readable"},
|
||||
"repeat": {"times": 1, "completed": 0},
|
||||
"enabled": true,
|
||||
"state": "scheduled|running|completed",
|
||||
"deliver": "origin|telegram|discord",
|
||||
"next_run_at": "ISO8601",
|
||||
"last_run_at": "ISO8601|null",
|
||||
"last_error": "string|null"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Config Structure (config.yaml)
|
||||
|
||||
Key sections: model (default, provider), agent (max_turns, tool_use_enforcement, personalities), terminal (backend, cwd, timeout), memory (enabled, char limits, nudge interval), display (personality, streaming, show_reasoning), platform_toolsets (tools per platform).
|
||||
|
||||
## ACP (Agent Client Protocol)
|
||||
|
||||
- Entry: `hermes acp` or `python -m acp_adapter.entry`
|
||||
- Transport: stdio JSON-RPC (not HTTP)
|
||||
- Lifecycle: initialize() -> new_session()/load_session() -> send messages
|
||||
- Events emitted: ToolCallStart, ToolCallProgress, AgentMessage, AgentThought, SessionUpdate
|
||||
- Tool kinds: read, edit, execute, fetch, search, think, other
|
||||
- Tool call IDs: `tc-{uuid.hex[:12]}`
|
||||
|
||||
## Log Format
|
||||
|
||||
```
|
||||
YYYY-MM-DD HH:MM:SS,MMM LEVEL logger_name: message
|
||||
```
|
||||
|
||||
## Gateway State
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"kind": "hermes-gateway",
|
||||
"gateway_state": "running|startup_failed",
|
||||
"exit_reason": "string|null",
|
||||
"platforms": {},
|
||||
"updated_at": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## SQLite Contention Notes
|
||||
|
||||
Hermes uses WAL mode with aggressive retry (15 retries, 20-150ms jitter). Scarf must only open state.db in read-only mode to avoid write contention. Checkpoint every 50 writes. WAL file modification is a good signal for refresh.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Scarf — Product Requirements Document
|
||||
|
||||
## Overview
|
||||
|
||||
Scarf is a native macOS application that provides a graphical interface for the Hermes AI agent. Hermes is a CLI-based AI agent with 40+ tools, multi-platform messaging, autonomous skill creation, persistent memory, and scheduled automation. Scarf gives users visibility into what Hermes is doing, when, and what it creates.
|
||||
|
||||
## Problem
|
||||
|
||||
Hermes operates entirely through CLI with no visual dashboard. Users cannot easily:
|
||||
- See what the agent is currently doing or has done
|
||||
- Browse conversation history across sessions
|
||||
- Monitor tool executions in real-time
|
||||
- Manage memory, skills, or cron jobs visually
|
||||
- Chat with the agent through a native interface
|
||||
|
||||
## Target User
|
||||
|
||||
Developer running Hermes locally on macOS who wants transparency and control over agent activity.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Dashboard
|
||||
- System health overview (model, provider, connection status)
|
||||
- Active session indicator
|
||||
- Token usage and cost summary (aggregated from session data)
|
||||
- Gateway platform connection status
|
||||
- Recent activity feed
|
||||
|
||||
### 2. Sessions Browser
|
||||
- List all conversation sessions with metadata (source, message count, tool calls, cost, duration)
|
||||
- Full conversation detail view with message rendering
|
||||
- Full-text search across all sessions (via SQLite FTS5)
|
||||
- Session lineage tracking (parent_session_id chains)
|
||||
|
||||
### 3. Activity Feed
|
||||
- Real-time tool execution monitoring (the core transparency feature)
|
||||
- Each entry: tool name, kind, arguments, result preview, timestamp
|
||||
- Filterable by tool type, session, time range
|
||||
- Color-coded by tool kind (read/edit/execute/fetch)
|
||||
|
||||
### 4. Live Chat
|
||||
- Send messages to Hermes via ACP (Agent Client Protocol)
|
||||
- Stream responses with tool calls shown inline
|
||||
- Session management (new, load, resume)
|
||||
|
||||
### 5. Memory Viewer/Editor
|
||||
- Display MEMORY.md and USER.md with markdown rendering
|
||||
- Edit and save changes
|
||||
- Character count vs configured limits
|
||||
|
||||
### 6. Skills Browser
|
||||
- Tree view by category
|
||||
- Skill metadata display
|
||||
- Search and filter
|
||||
|
||||
### 7. Cron Manager
|
||||
- View scheduled jobs with status, next/last run times
|
||||
- View job output
|
||||
- Enable/disable jobs
|
||||
|
||||
### 8. Log Viewer
|
||||
- Real-time log tailing (errors.log, gateway.log)
|
||||
- Level-based filtering and text search
|
||||
|
||||
### 9. Menu Bar Presence
|
||||
- Status icon showing Hermes state (running/idle/error)
|
||||
- Quick access to recent session, new chat
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
- macOS 26.2+ (SwiftUI, Swift 6 concurrency)
|
||||
- No external SPM dependencies — uses system SQLite3 C API, Foundation JSON
|
||||
- Reads Hermes data from `~/.hermes/` (requires sandbox disabled)
|
||||
- ACP communication via subprocess stdio JSON-RPC
|
||||
- App sandbox disabled (developer tool needing filesystem access)
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Source | Path | Format | Access |
|
||||
|--------|------|--------|--------|
|
||||
| Sessions DB | `~/.hermes/state.db` | SQLite (WAL) | Read-only |
|
||||
| Session files | `~/.hermes/sessions/*.json` | JSON | Read-only |
|
||||
| Config | `~/.hermes/config.yaml` | YAML | Read/Write |
|
||||
| Memory | `~/.hermes/memories/*.md` | Markdown | Read/Write |
|
||||
| Cron jobs | `~/.hermes/cron/jobs.json` | JSON | Read/Write |
|
||||
| Cron output | `~/.hermes/cron/output/` | Text | Read-only |
|
||||
| Logs | `~/.hermes/logs/*.log` | Text | Read-only |
|
||||
| Gateway state | `~/.hermes/gateway_state.json` | JSON | Read-only |
|
||||
| Skills | `~/.hermes/skills/` | Directory tree | Read-only |
|
||||
| ACP | `hermes acp` subprocess | JSON-RPC stdio | Bidirectional |
|
||||
|
||||
## Non-Goals (v1)
|
||||
|
||||
- Config editing UI (read-only display for v1, except memory)
|
||||
- Skill creation or management
|
||||
- Gateway platform management
|
||||
- Multi-user support
|
||||
@@ -0,0 +1,58 @@
|
||||
# Scarf — Feature Roadmap
|
||||
|
||||
## Tier 1 — High Value, Data Already Available
|
||||
|
||||
### 1. Insights Dashboard
|
||||
Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
|
||||
- Model breakdown: sessions and tokens per model
|
||||
- Platform breakdown: CLI vs Telegram vs Discord usage
|
||||
- Top tools chart: ranked tool usage with call counts and percentages
|
||||
- Activity patterns: sessions by day-of-week, peak hours heatmap
|
||||
- Notable sessions: longest, most messages, most tokens, most tool calls
|
||||
- Time period selector: last 7/30/90 days
|
||||
|
||||
### 2. Tool Management Panel
|
||||
- List all toolsets with enabled/disabled status and descriptions
|
||||
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
|
||||
- Per-platform tool configuration
|
||||
- MCP tool status
|
||||
|
||||
### 3. Session Management Enhancements
|
||||
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
|
||||
- Delete sessions (via `hermes sessions delete`)
|
||||
- Export sessions to JSONL (via `hermes sessions export`)
|
||||
- Session stats card (total count, DB size, per-platform breakdown)
|
||||
|
||||
## Tier 2 — Medium Value, New Service Code Required
|
||||
|
||||
### 4. Skills Hub
|
||||
- Search remote registries for new skills (6 sources)
|
||||
- Install/uninstall skills from GUI
|
||||
- Skill update indicator
|
||||
- Trust level badges (builtin, local, hub)
|
||||
|
||||
### 5. Gateway Control Center
|
||||
- Start/stop/restart gateway from GUI
|
||||
- Real-time status: PID, uptime, connected platforms
|
||||
- Pairing management: view approved users, approve/revoke
|
||||
- Platform status per messaging service
|
||||
|
||||
### 6. System Health View
|
||||
- Mirror `hermes status` and `hermes doctor` output
|
||||
- API key validation, auth provider status, external tools
|
||||
- Update available indicator
|
||||
|
||||
## Tier 3 — Nice to Have
|
||||
|
||||
### 7. Profile Management
|
||||
- List/create/switch profiles (isolated Hermes instances)
|
||||
|
||||
### 8. Plugin Management
|
||||
- Install from Git, enable/disable, update
|
||||
|
||||
### 9. MCP Server Management
|
||||
- Add/remove/test MCP servers, toggle tools per server
|
||||
|
||||
### 10. Config Editor
|
||||
- Structured form editor for config.yaml with validation
|
||||
@@ -0,0 +1,640 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5349593F2F7B83B600BD31AD;
|
||||
remoteInfo = scarf;
|
||||
};
|
||||
5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5349593F2F7B83B600BD31AD;
|
||||
remoteInfo = scarf;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
534959422F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
|
||||
);
|
||||
path = scarf;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534959522F7B83B700BD31AD /* scarfTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = scarfTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = scarfUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5349593D2F7B83B600BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594C2F7B83B700BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959562F7B83B700BD31AD /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
534959372F7B83B600BD31AD = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534959422F7B83B600BD31AD /* scarf */,
|
||||
534959522F7B83B700BD31AD /* scarfTests */,
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||
534959412F7B83B600BD31AD /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
534959412F7B83B600BD31AD /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
534959402F7B83B600BD31AD /* scarf.app */,
|
||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
|
||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5349593F2F7B83B600BD31AD /* scarf */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
|
||||
buildPhases = (
|
||||
5349593C2F7B83B600BD31AD /* Sources */,
|
||||
5349593D2F7B83B600BD31AD /* Frameworks */,
|
||||
5349593E2F7B83B600BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
534959422F7B83B600BD31AD /* scarf */,
|
||||
);
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
53SPARKLE00011 /* Sparkle */,
|
||||
);
|
||||
productName = scarf;
|
||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
5349594E2F7B83B700BD31AD /* scarfTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */;
|
||||
buildPhases = (
|
||||
5349594B2F7B83B700BD31AD /* Sources */,
|
||||
5349594C2F7B83B700BD31AD /* Frameworks */,
|
||||
5349594D2F7B83B700BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
534959512F7B83B700BD31AD /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
534959522F7B83B700BD31AD /* scarfTests */,
|
||||
);
|
||||
name = scarfTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = scarfTests;
|
||||
productReference = 5349594F2F7B83B700BD31AD /* scarfTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
534959582F7B83B700BD31AD /* scarfUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */;
|
||||
buildPhases = (
|
||||
534959552F7B83B700BD31AD /* Sources */,
|
||||
534959562F7B83B700BD31AD /* Frameworks */,
|
||||
534959572F7B83B700BD31AD /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5349595B2F7B83B700BD31AD /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||
);
|
||||
name = scarfUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = scarfUITests;
|
||||
productReference = 534959592F7B83B700BD31AD /* scarfUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
534959382F7B83B600BD31AD /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
5349593F2F7B83B600BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
5349594E2F7B83B700BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 5349593F2F7B83B600BD31AD;
|
||||
};
|
||||
534959582F7B83B700BD31AD = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = 5349593F2F7B83B600BD31AD;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 534959372F7B83B600BD31AD;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
5349593F2F7B83B600BD31AD /* scarf */,
|
||||
5349594E2F7B83B700BD31AD /* scarfTests */,
|
||||
534959582F7B83B700BD31AD /* scarfUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
5349593E2F7B83B600BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594D2F7B83B700BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959572F7B83B700BD31AD /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
5349593C2F7B83B600BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5349594B2F7B83B700BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
534959552F7B83B700BD31AD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
targetProxy = 534959502F7B83B700BD31AD /* PBXContainerItemProxy */;
|
||||
};
|
||||
5349595B2F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||
targetProxy = 5349595A2F7B83B700BD31AD /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
534959612F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959622F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
534959642F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959652F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
534959672F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
534959682F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/scarf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/scarf";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
5349596A2F7B83B700BD31AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = scarf;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
5349596B2F7B83B700BD31AD /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = scarf;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959612F7B83B700BD31AD /* Debug */,
|
||||
534959622F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959642F7B83B700BD31AD /* Debug */,
|
||||
534959652F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959662F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
534959672F7B83B700BD31AD /* Debug */,
|
||||
534959682F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
534959692F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarfUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
5349596A2F7B83B700BD31AD /* Debug */,
|
||||
5349596B2F7B83B700BD31AD /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.6.0;
|
||||
};
|
||||
};
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SPARKLE00011 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
productName = SwiftTerm;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 534959382F7B83B600BD31AD /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 962 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
/// Per-window connection status. Constructed from the window's
|
||||
/// `serverContext` once; lifetime matches the window.
|
||||
@State private var connectionStatus: ConnectionStatusViewModel
|
||||
|
||||
init() {
|
||||
_connectionStatus = State(initialValue: ConnectionStatusViewModel(context: .local))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
} detail: {
|
||||
detailView
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
ServerSwitcherToolbar()
|
||||
}
|
||||
if serverContext.isRemote {
|
||||
ToolbarItem(placement: .principal) {
|
||||
ConnectionStatusPill(status: connectionStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// The actual context is injected via @Environment, which
|
||||
// isn't available in `init`. Rebuild the monitor here
|
||||
// the first time we know the real context. Safe to call
|
||||
// repeatedly; `startMonitoring()` cancels + restarts.
|
||||
if connectionStatus.context.id != serverContext.id {
|
||||
connectionStatus = ConnectionStatusViewModel(context: serverContext)
|
||||
}
|
||||
connectionStatus.startMonitoring()
|
||||
}
|
||||
.onDisappear { connectionStatus.stopMonitoring() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
// Each routed view receives the window's `serverContext` in its
|
||||
// init so its `@State` ViewModel is constructed bound to the right
|
||||
// server. This is what makes multi-window work — without it,
|
||||
// every window's VMs default-construct with `.local` even though
|
||||
// the surrounding env has the right context.
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard: DashboardView(context: serverContext)
|
||||
case .insights: InsightsView(context: serverContext)
|
||||
case .sessions: SessionsView(context: serverContext)
|
||||
case .activity: ActivityView(context: serverContext)
|
||||
case .projects: ProjectsView(context: serverContext)
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView(context: serverContext)
|
||||
case .skills: SkillsView(context: serverContext)
|
||||
case .platforms: PlatformsView(context: serverContext)
|
||||
case .personalities: PersonalitiesView(context: serverContext)
|
||||
case .quickCommands: QuickCommandsView(context: serverContext)
|
||||
case .credentialPools: CredentialPoolsView(context: serverContext)
|
||||
case .plugins: PluginsView(context: serverContext)
|
||||
case .webhooks: WebhooksView(context: serverContext)
|
||||
case .profiles: ProfilesView(context: serverContext)
|
||||
case .tools: ToolsView(context: serverContext)
|
||||
case .mcpServers: MCPServersView(context: serverContext)
|
||||
case .gateway: GatewayView(context: serverContext)
|
||||
case .cron: CronView(context: serverContext)
|
||||
case .health: HealthView(context: serverContext)
|
||||
case .logs: LogsView(context: serverContext)
|
||||
case .settings: SettingsView(context: serverContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
|
||||
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
|
||||
// conformance — which would prevent these payloads from being encoded or
|
||||
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
||||
// The member list must stay in sync with the stored properties above.
|
||||
|
||||
struct ACPRequest: Encodable, Sendable {
|
||||
nonisolated let jsonrpc = "2.0"
|
||||
nonisolated let id: Int
|
||||
nonisolated let method: String
|
||||
nonisolated let params: [String: AnyCodable]
|
||||
|
||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(method, forKey: .method)
|
||||
try c.encode(params, forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPRawMessage: Decodable, Sendable {
|
||||
nonisolated let jsonrpc: String?
|
||||
nonisolated let id: Int?
|
||||
nonisolated let method: String?
|
||||
nonisolated let result: AnyCodable?
|
||||
nonisolated let error: ACPError?
|
||||
nonisolated let params: AnyCodable?
|
||||
|
||||
nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||
nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||
nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||
self.method = try c.decodeIfPresent(String.self, forKey: .method)
|
||||
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
|
||||
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
|
||||
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPError: Decodable, Sendable {
|
||||
nonisolated let code: Int
|
||||
nonisolated let message: String
|
||||
|
||||
enum CodingKeys: String, CodingKey { case code, message }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.code = try c.decode(Int.self, forKey: .code)
|
||||
self.message = try c.decode(String.self, forKey: .message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AnyCodable (for dynamic JSON)
|
||||
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
nonisolated let value: Any
|
||||
|
||||
nonisolated init(_ value: Any) { self.value = value }
|
||||
|
||||
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
||||
// `let value: Any` stored property as MainActor-isolated even when the
|
||||
// property is declared nonisolated (Any can't be strictly Sendable, so
|
||||
// the compiler can't prove the write is safe off-main). Leaving the
|
||||
// init as default-isolated silences the mutation warnings; the Decodable
|
||||
// conformance is still usable from ACPClient's nonisolated read loop
|
||||
// because all callers are already @preconcurrency with respect to
|
||||
// `AnyCodable` (it's @unchecked Sendable).
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map(\.value)
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues(\.value)
|
||||
} else {
|
||||
value = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is NSNull:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessors
|
||||
|
||||
nonisolated var stringValue: String? { value as? String }
|
||||
nonisolated var intValue: Int? { value as? Int }
|
||||
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||
}
|
||||
|
||||
// MARK: - ACP Events (parsed from session/update notifications)
|
||||
|
||||
enum ACPEvent: Sendable {
|
||||
case messageChunk(sessionId: String, text: String)
|
||||
case thoughtChunk(sessionId: String, text: String)
|
||||
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
|
||||
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
|
||||
case promptComplete(sessionId: String, response: ACPPromptResult)
|
||||
case availableCommands(sessionId: String, commands: [[String: Any]])
|
||||
case connectionLost(reason: String)
|
||||
case unknown(sessionId: String, type: String)
|
||||
}
|
||||
|
||||
struct ACPToolCallEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let title: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawInput: [String: Any]?
|
||||
|
||||
var functionName: String {
|
||||
// title format is "functionName: summary" or just "functionName"
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
var argumentsSummary: String {
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
if parts.count > 1 {
|
||||
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var argumentsJSON: String {
|
||||
guard let input = rawInput,
|
||||
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPToolCallUpdateEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawOutput: String?
|
||||
}
|
||||
|
||||
struct ACPPermissionRequestEvent: Sendable {
|
||||
let toolCallTitle: String
|
||||
let toolCallKind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
}
|
||||
|
||||
struct ACPPromptResult: Sendable {
|
||||
let stopReason: String
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let thoughtTokens: Int
|
||||
let cachedReadTokens: Int
|
||||
}
|
||||
|
||||
// MARK: - Event Parsing
|
||||
|
||||
enum ACPEventParser {
|
||||
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
guard notification.method == "session/update",
|
||||
let params = notification.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
let update = params["update"] as? [String: Any],
|
||||
let updateType = update["sessionUpdate"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch updateType {
|
||||
case "agent_message_chunk":
|
||||
let text = extractContentText(from: update)
|
||||
return .messageChunk(sessionId: sessionId, text: text)
|
||||
|
||||
case "agent_thought_chunk":
|
||||
let text = extractContentText(from: update)
|
||||
return .thoughtChunk(sessionId: sessionId, text: text)
|
||||
|
||||
case "tool_call":
|
||||
let event = ACPToolCallEvent(
|
||||
toolCallId: update["toolCallId"] as? String ?? "",
|
||||
title: update["title"] as? String ?? "",
|
||||
kind: update["kind"] as? String ?? "other",
|
||||
status: update["status"] as? String ?? "pending",
|
||||
content: extractContentArrayText(from: update),
|
||||
rawInput: update["rawInput"] as? [String: Any]
|
||||
)
|
||||
return .toolCallStart(sessionId: sessionId, call: event)
|
||||
|
||||
case "tool_call_update":
|
||||
let event = ACPToolCallUpdateEvent(
|
||||
toolCallId: update["toolCallId"] as? String ?? "",
|
||||
kind: update["kind"] as? String ?? "other",
|
||||
status: update["status"] as? String ?? "completed",
|
||||
content: extractContentArrayText(from: update),
|
||||
rawOutput: update["rawOutput"] as? String
|
||||
)
|
||||
return .toolCallUpdate(sessionId: sessionId, update: event)
|
||||
|
||||
case "available_commands_update":
|
||||
let commands = update["availableCommands"] as? [[String: Any]] ?? []
|
||||
return .availableCommands(sessionId: sessionId, commands: commands)
|
||||
|
||||
default:
|
||||
return .unknown(sessionId: sessionId, type: updateType)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||
guard message.method == "session/request_permission",
|
||||
let params = message.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
let requestId = message.id else { return nil }
|
||||
|
||||
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
|
||||
let optionsRaw = params["options"] as? [[String: Any]] ?? []
|
||||
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
|
||||
guard let id = opt["optionId"] as? String,
|
||||
let name = opt["name"] as? String else { return nil }
|
||||
return (optionId: id, name: name)
|
||||
}
|
||||
|
||||
let event = ACPPermissionRequestEvent(
|
||||
toolCallTitle: toolCall["title"] as? String ?? "",
|
||||
toolCallKind: toolCall["kind"] as? String ?? "other",
|
||||
options: options
|
||||
)
|
||||
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
|
||||
}
|
||||
|
||||
// MARK: - Content Extraction
|
||||
|
||||
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
|
||||
if let content = update["content"] as? [String: Any],
|
||||
let text = content["text"] as? String {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
|
||||
if let contentArray = update["content"] as? [[String: Any]] {
|
||||
return contentArray.compactMap { item -> String? in
|
||||
guard let inner = item["content"] as? [String: Any] else { return nil }
|
||||
return inner["text"] as? String
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import Foundation
|
||||
|
||||
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
|
||||
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
struct AuxiliaryModel: Sendable, Equatable {
|
||||
var provider: String
|
||||
var model: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var timeout: Int
|
||||
|
||||
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
||||
}
|
||||
|
||||
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
||||
struct DisplaySettings: Sendable, Equatable {
|
||||
var skin: String
|
||||
var compact: Bool
|
||||
var resumeDisplay: String // "full" | "minimal"
|
||||
var bellOnComplete: Bool
|
||||
var inlineDiffs: Bool
|
||||
var toolProgressCommand: Bool
|
||||
var toolPreviewLength: Int
|
||||
var busyInputMode: String // e.g. "interrupt"
|
||||
|
||||
nonisolated static let empty = DisplaySettings(
|
||||
skin: "default",
|
||||
compact: false,
|
||||
resumeDisplay: "full",
|
||||
bellOnComplete: false,
|
||||
inlineDiffs: true,
|
||||
toolProgressCommand: false,
|
||||
toolPreviewLength: 0,
|
||||
busyInputMode: "interrupt"
|
||||
)
|
||||
}
|
||||
|
||||
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
|
||||
struct TerminalSettings: Sendable, Equatable {
|
||||
var cwd: String
|
||||
var timeout: Int
|
||||
var envPassthrough: [String]
|
||||
var persistentShell: Bool
|
||||
var dockerImage: String
|
||||
var dockerMountCwdToWorkspace: Bool
|
||||
var dockerForwardEnv: [String]
|
||||
var dockerVolumes: [String]
|
||||
var containerCPU: Int // 0 = unlimited
|
||||
var containerMemory: Int // MB, 0 = unlimited
|
||||
var containerDisk: Int // MB, 0 = unlimited
|
||||
var containerPersistent: Bool
|
||||
var modalImage: String
|
||||
var modalMode: String // "auto" | other
|
||||
var daytonaImage: String
|
||||
var singularityImage: String
|
||||
|
||||
nonisolated static let empty = TerminalSettings(
|
||||
cwd: ".",
|
||||
timeout: 180,
|
||||
envPassthrough: [],
|
||||
persistentShell: true,
|
||||
dockerImage: "",
|
||||
dockerMountCwdToWorkspace: false,
|
||||
dockerForwardEnv: [],
|
||||
dockerVolumes: [],
|
||||
containerCPU: 0,
|
||||
containerMemory: 0,
|
||||
containerDisk: 0,
|
||||
containerPersistent: false,
|
||||
modalImage: "",
|
||||
modalMode: "auto",
|
||||
daytonaImage: "",
|
||||
singularityImage: ""
|
||||
)
|
||||
}
|
||||
|
||||
/// Browser automation tuning (`browser.*`).
|
||||
struct BrowserSettings: Sendable, Equatable {
|
||||
var inactivityTimeout: Int
|
||||
var commandTimeout: Int
|
||||
var recordSessions: Bool
|
||||
var allowPrivateURLs: Bool
|
||||
var camofoxManagedPersistence: Bool
|
||||
|
||||
nonisolated static let empty = BrowserSettings(
|
||||
inactivityTimeout: 120,
|
||||
commandTimeout: 30,
|
||||
recordSessions: false,
|
||||
allowPrivateURLs: false,
|
||||
camofoxManagedPersistence: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Voice push-to-talk plus TTS/STT provider settings.
|
||||
struct VoiceSettings: Sendable, Equatable {
|
||||
var recordKey: String
|
||||
var maxRecordingSeconds: Int
|
||||
var silenceDuration: Double
|
||||
|
||||
// TTS
|
||||
var ttsProvider: String
|
||||
var ttsEdgeVoice: String
|
||||
var ttsElevenLabsVoiceID: String
|
||||
var ttsElevenLabsModelID: String
|
||||
var ttsOpenAIModel: String
|
||||
var ttsOpenAIVoice: String
|
||||
var ttsNeuTTSModel: String
|
||||
var ttsNeuTTSDevice: String
|
||||
|
||||
// STT
|
||||
var sttEnabled: Bool
|
||||
var sttProvider: String
|
||||
var sttLocalModel: String
|
||||
var sttLocalLanguage: String
|
||||
var sttOpenAIModel: String
|
||||
var sttMistralModel: String
|
||||
|
||||
nonisolated static let empty = VoiceSettings(
|
||||
recordKey: "ctrl+b",
|
||||
maxRecordingSeconds: 120,
|
||||
silenceDuration: 3.0,
|
||||
ttsProvider: "edge",
|
||||
ttsEdgeVoice: "en-US-AriaNeural",
|
||||
ttsElevenLabsVoiceID: "",
|
||||
ttsElevenLabsModelID: "eleven_multilingual_v2",
|
||||
ttsOpenAIModel: "gpt-4o-mini-tts",
|
||||
ttsOpenAIVoice: "alloy",
|
||||
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
|
||||
ttsNeuTTSDevice: "cpu",
|
||||
sttEnabled: true,
|
||||
sttProvider: "local",
|
||||
sttLocalModel: "base",
|
||||
sttLocalLanguage: "",
|
||||
sttOpenAIModel: "whisper-1",
|
||||
sttMistralModel: "voxtral-mini-latest"
|
||||
)
|
||||
}
|
||||
|
||||
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||
struct AuxiliarySettings: Sendable, Equatable {
|
||||
var vision: AuxiliaryModel
|
||||
var webExtract: AuxiliaryModel
|
||||
var compression: AuxiliaryModel
|
||||
var sessionSearch: AuxiliaryModel
|
||||
var skillsHub: AuxiliaryModel
|
||||
var approval: AuxiliaryModel
|
||||
var mcp: AuxiliaryModel
|
||||
var flushMemories: AuxiliaryModel
|
||||
|
||||
nonisolated static let empty = AuxiliarySettings(
|
||||
vision: .empty,
|
||||
webExtract: .empty,
|
||||
compression: .empty,
|
||||
sessionSearch: .empty,
|
||||
skillsHub: .empty,
|
||||
approval: .empty,
|
||||
mcp: .empty,
|
||||
flushMemories: .empty
|
||||
)
|
||||
}
|
||||
|
||||
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
|
||||
struct SecuritySettings: Sendable, Equatable {
|
||||
var redactSecrets: Bool
|
||||
var redactPII: Bool // from privacy.redact_pii
|
||||
var tirithEnabled: Bool
|
||||
var tirithPath: String
|
||||
var tirithTimeout: Int
|
||||
var tirithFailOpen: Bool
|
||||
var blocklistEnabled: Bool
|
||||
var blocklistDomains: [String]
|
||||
|
||||
nonisolated static let empty = SecuritySettings(
|
||||
redactSecrets: true,
|
||||
redactPII: false,
|
||||
tirithEnabled: true,
|
||||
tirithPath: "tirith",
|
||||
tirithTimeout: 5,
|
||||
tirithFailOpen: true,
|
||||
blocklistEnabled: false,
|
||||
blocklistDomains: []
|
||||
)
|
||||
}
|
||||
|
||||
/// Human-delay simulates realistic typing pace (`human_delay.*`).
|
||||
struct HumanDelaySettings: Sendable, Equatable {
|
||||
var mode: String // "off" | "natural" | "custom"
|
||||
var minMS: Int
|
||||
var maxMS: Int
|
||||
|
||||
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
||||
}
|
||||
|
||||
/// Compression / context routing.
|
||||
struct CompressionSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var threshold: Double
|
||||
var targetRatio: Double
|
||||
var protectLastN: Int
|
||||
|
||||
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||
}
|
||||
|
||||
struct CheckpointSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var maxSnapshots: Int
|
||||
|
||||
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
||||
}
|
||||
|
||||
struct LoggingSettings: Sendable, Equatable {
|
||||
var level: String // DEBUG | INFO | WARNING | ERROR
|
||||
var maxSizeMB: Int
|
||||
var backupCount: Int
|
||||
|
||||
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
||||
}
|
||||
|
||||
struct DelegationSettings: Sendable, Equatable {
|
||||
var model: String
|
||||
var provider: String
|
||||
var baseURL: String
|
||||
var apiKey: String
|
||||
var maxIterations: Int
|
||||
|
||||
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
||||
}
|
||||
|
||||
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
||||
struct DiscordSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var freeResponseChannels: String
|
||||
var autoThread: Bool
|
||||
var reactions: Bool
|
||||
|
||||
nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
|
||||
}
|
||||
|
||||
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
|
||||
/// done via environment variables (`TELEGRAM_*`) — this is the subset that lives
|
||||
/// in the YAML.
|
||||
struct TelegramSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var reactions: Bool
|
||||
|
||||
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
|
||||
}
|
||||
|
||||
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
||||
struct SlackSettings: Sendable, Equatable {
|
||||
var replyToMode: String // "off" | "first" | "all"
|
||||
var requireMention: Bool
|
||||
var replyInThread: Bool
|
||||
var replyBroadcast: Bool
|
||||
|
||||
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
||||
}
|
||||
|
||||
/// Matrix settings under `matrix.*`.
|
||||
struct MatrixSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var autoThread: Bool
|
||||
var dmMentionThreads: Bool
|
||||
|
||||
nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
|
||||
}
|
||||
|
||||
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
|
||||
/// currently just exposes `group_sessions_per_user` at the top level, but we
|
||||
/// reserve this struct for future expansion so the form has a stable type.
|
||||
struct MattermostSettings: Sendable, Equatable {
|
||||
var requireMention: Bool
|
||||
var replyMode: String // "thread" | "off"
|
||||
|
||||
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||
}
|
||||
|
||||
/// WhatsApp settings under `whatsapp.*`.
|
||||
struct WhatsAppSettings: Sendable, Equatable {
|
||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||
var replyPrefix: String
|
||||
|
||||
nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
|
||||
}
|
||||
|
||||
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
|
||||
/// every state change by default; users must opt-in via at least one filter.
|
||||
struct HomeAssistantSettings: Sendable, Equatable {
|
||||
var watchDomains: [String]
|
||||
var watchEntities: [String]
|
||||
var watchAll: Bool
|
||||
var ignoreEntities: [String]
|
||||
var cooldownSeconds: Int
|
||||
|
||||
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
||||
}
|
||||
|
||||
// MARK: - Root Config
|
||||
|
||||
struct HermesConfig: Sendable {
|
||||
// Original fields — preserved for zero breakage with existing call sites.
|
||||
var model: String
|
||||
var provider: String
|
||||
var maxTurns: Int
|
||||
var personality: String
|
||||
var terminalBackend: String
|
||||
var memoryEnabled: Bool
|
||||
var memoryCharLimit: Int
|
||||
var userCharLimit: Int
|
||||
var nudgeInterval: Int
|
||||
var streaming: Bool
|
||||
var showReasoning: Bool
|
||||
var verbose: Bool
|
||||
var autoTTS: Bool
|
||||
var silenceThreshold: Int
|
||||
var reasoningEffort: String
|
||||
var showCost: Bool
|
||||
var approvalMode: String
|
||||
var browserBackend: String
|
||||
var memoryProvider: String
|
||||
var dockerEnv: [String: String]
|
||||
var commandAllowlist: [String]
|
||||
var memoryProfile: String
|
||||
var serviceTier: String
|
||||
var gatewayNotifyInterval: Int
|
||||
var forceIPv4: Bool
|
||||
var contextEngine: String
|
||||
var interimAssistantMessages: Bool
|
||||
var honchoInitOnSessionStart: Bool
|
||||
|
||||
// Phase 1 additions
|
||||
var timezone: String
|
||||
var userProfileEnabled: Bool
|
||||
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
||||
var gatewayTimeout: Int
|
||||
var approvalTimeout: Int
|
||||
var fileReadMaxChars: Int
|
||||
var cronWrapResponse: Bool
|
||||
var prefillMessagesFile: String
|
||||
var skillsExternalDirs: [String]
|
||||
|
||||
// Grouped blocks
|
||||
var display: DisplaySettings
|
||||
var terminal: TerminalSettings
|
||||
var browser: BrowserSettings
|
||||
var voice: VoiceSettings
|
||||
var auxiliary: AuxiliarySettings
|
||||
var security: SecuritySettings
|
||||
var humanDelay: HumanDelaySettings
|
||||
var compression: CompressionSettings
|
||||
var checkpoints: CheckpointSettings
|
||||
var logging: LoggingSettings
|
||||
var delegation: DelegationSettings
|
||||
var discord: DiscordSettings
|
||||
var telegram: TelegramSettings
|
||||
var slack: SlackSettings
|
||||
var matrix: MatrixSettings
|
||||
var mattermost: MattermostSettings
|
||||
var whatsapp: WhatsAppSettings
|
||||
var homeAssistant: HomeAssistantSettings
|
||||
|
||||
nonisolated static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
provider: "unknown",
|
||||
maxTurns: 0,
|
||||
personality: "default",
|
||||
terminalBackend: "local",
|
||||
memoryEnabled: false,
|
||||
memoryCharLimit: 0,
|
||||
userCharLimit: 0,
|
||||
nudgeInterval: 0,
|
||||
streaming: true,
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200,
|
||||
reasoningEffort: "medium",
|
||||
showCost: false,
|
||||
approvalMode: "manual",
|
||||
browserBackend: "",
|
||||
memoryProvider: "",
|
||||
dockerEnv: [:],
|
||||
commandAllowlist: [],
|
||||
memoryProfile: "",
|
||||
serviceTier: "normal",
|
||||
gatewayNotifyInterval: 600,
|
||||
forceIPv4: false,
|
||||
contextEngine: "compressor",
|
||||
interimAssistantMessages: true,
|
||||
honchoInitOnSessionStart: false,
|
||||
timezone: "",
|
||||
userProfileEnabled: true,
|
||||
toolUseEnforcement: "auto",
|
||||
gatewayTimeout: 1800,
|
||||
approvalTimeout: 60,
|
||||
fileReadMaxChars: 100_000,
|
||||
cronWrapResponse: true,
|
||||
prefillMessagesFile: "",
|
||||
skillsExternalDirs: [],
|
||||
display: .empty,
|
||||
terminal: .empty,
|
||||
browser: .empty,
|
||||
voice: .empty,
|
||||
auxiliary: .empty,
|
||||
security: .empty,
|
||||
humanDelay: .empty,
|
||||
compression: .empty,
|
||||
checkpoints: .empty,
|
||||
logging: .empty,
|
||||
delegation: .empty,
|
||||
discord: .empty,
|
||||
telegram: .empty,
|
||||
slack: .empty,
|
||||
matrix: .empty,
|
||||
mattermost: .empty,
|
||||
whatsapp: .empty,
|
||||
homeAssistant: .empty
|
||||
)
|
||||
}
|
||||
|
||||
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
|
||||
// MainActor-isolated Decodable conformance (which would fail to be used from
|
||||
// `HermesFileService.loadGatewayState()`, a nonisolated method).
|
||||
struct GatewayState: Sendable, Codable {
|
||||
nonisolated let pid: Int?
|
||||
nonisolated let kind: String?
|
||||
nonisolated let gatewayState: String?
|
||||
nonisolated let exitReason: String?
|
||||
nonisolated let platforms: [String: PlatformState]?
|
||||
nonisolated let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pid, kind
|
||||
case gatewayState = "gateway_state"
|
||||
case exitReason = "exit_reason"
|
||||
case platforms
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
|
||||
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
|
||||
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
|
||||
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
|
||||
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
|
||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(pid, forKey: .pid)
|
||||
try c.encodeIfPresent(kind, forKey: .kind)
|
||||
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
|
||||
try c.encodeIfPresent(exitReason, forKey: .exitReason)
|
||||
try c.encodeIfPresent(platforms, forKey: .platforms)
|
||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated var isRunning: Bool {
|
||||
gatewayState == "running"
|
||||
}
|
||||
|
||||
nonisolated var statusText: String {
|
||||
gatewayState ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
struct PlatformState: Sendable, Codable {
|
||||
nonisolated let connected: Bool?
|
||||
nonisolated let error: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey { case connected, error }
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
|
||||
self.error = try c.decodeIfPresent(String.self, forKey: .error)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encodeIfPresent(connected, forKey: .connected)
|
||||
try c.encodeIfPresent(error, forKey: .error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// Deprecated module-level path statics. Preserved as thin forwarders to
|
||||
/// `ServerContext.local.paths` so existing call sites continue to compile
|
||||
/// while Phase 1 migrates them to a per-server `ServerContext`.
|
||||
///
|
||||
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
|
||||
enum HermesPaths: Sendable {
|
||||
@available(*, deprecated, message: "use ServerContext.paths.home")
|
||||
nonisolated static var home: String { ServerContext.local.paths.home }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.stateDB")
|
||||
nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.configYAML")
|
||||
nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
|
||||
nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.memoryMD")
|
||||
nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.userMD")
|
||||
nonisolated static var userMD: String { ServerContext.local.paths.userMD }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.sessionsDir")
|
||||
nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON")
|
||||
nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.cronOutputDir")
|
||||
nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON")
|
||||
nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.skillsDir")
|
||||
nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.errorsLog")
|
||||
nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.agentLog")
|
||||
nonisolated static var agentLog: String { ServerContext.local.paths.agentLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.gatewayLog")
|
||||
nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.scarfDir")
|
||||
nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.projectsRegistry")
|
||||
nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry }
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir")
|
||||
nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir }
|
||||
|
||||
@available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates")
|
||||
nonisolated static var hermesBinaryCandidates: [String] {
|
||||
HermesPathSet.hermesBinaryCandidates
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
|
||||
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
|
||||
}
|
||||
|
||||
// MARK: - SQLite Constants
|
||||
|
||||
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
||||
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
||||
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
// MARK: - Query Defaults
|
||||
|
||||
enum QueryDefaults: Sendable {
|
||||
nonisolated static let sessionLimit = 100
|
||||
nonisolated static let messageSearchLimit = 50
|
||||
nonisolated static let toolCallLimit = 50
|
||||
nonisolated static let sessionPreviewLimit = 10
|
||||
nonisolated static let previewContentLength = 100
|
||||
nonisolated static let logLineLimit = 200
|
||||
nonisolated static let defaultSilenceThreshold = 200
|
||||
}
|
||||
|
||||
// MARK: - File Size Formatting
|
||||
|
||||
enum FileSizeUnit: Sendable {
|
||||
nonisolated static let kilobyte = 1_024.0
|
||||
nonisolated static let megabyte = 1_048_576.0
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
nonisolated let id: String
|
||||
nonisolated let name: String
|
||||
nonisolated let prompt: String
|
||||
nonisolated let skills: [String]?
|
||||
nonisolated let model: String?
|
||||
nonisolated let schedule: CronSchedule
|
||||
nonisolated let enabled: Bool
|
||||
nonisolated let state: String
|
||||
nonisolated let deliver: String?
|
||||
nonisolated let nextRunAt: String?
|
||||
nonisolated let lastRunAt: String?
|
||||
nonisolated let lastError: String?
|
||||
nonisolated let preRunScript: String?
|
||||
nonisolated let deliveryFailures: Int?
|
||||
nonisolated let lastDeliveryError: String?
|
||||
nonisolated let timeoutType: String?
|
||||
nonisolated let timeoutSeconds: Int?
|
||||
nonisolated let silent: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||
case nextRunAt = "next_run_at"
|
||||
case lastRunAt = "last_run_at"
|
||||
case lastError = "last_error"
|
||||
case preRunScript = "pre_run_script"
|
||||
case deliveryFailures = "delivery_failures"
|
||||
case lastDeliveryError = "last_delivery_error"
|
||||
case timeoutType = "timeout_type"
|
||||
case timeoutSeconds = "timeout_seconds"
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decode(String.self, forKey: .id)
|
||||
self.name = try c.decode(String.self, forKey: .name)
|
||||
self.prompt = try c.decode(String.self, forKey: .prompt)
|
||||
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
|
||||
self.model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
|
||||
self.enabled = try c.decode(Bool.self, forKey: .enabled)
|
||||
self.state = try c.decode(String.self, forKey: .state)
|
||||
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
|
||||
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
|
||||
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
|
||||
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
|
||||
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
|
||||
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
|
||||
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
|
||||
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)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(id, forKey: .id)
|
||||
try c.encode(name, forKey: .name)
|
||||
try c.encode(prompt, forKey: .prompt)
|
||||
try c.encodeIfPresent(skills, forKey: .skills)
|
||||
try c.encodeIfPresent(model, forKey: .model)
|
||||
try c.encode(schedule, forKey: .schedule)
|
||||
try c.encode(enabled, forKey: .enabled)
|
||||
try c.encode(state, forKey: .state)
|
||||
try c.encodeIfPresent(deliver, forKey: .deliver)
|
||||
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
|
||||
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
|
||||
try c.encodeIfPresent(lastError, forKey: .lastError)
|
||||
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
|
||||
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
|
||||
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
|
||||
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
||||
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||
try c.encodeIfPresent(silent, forKey: .silent)
|
||||
}
|
||||
|
||||
nonisolated var stateIcon: String {
|
||||
switch state {
|
||||
case "scheduled": return "clock"
|
||||
case "running": return "play.circle"
|
||||
case "completed": return "checkmark.circle"
|
||||
case "failed": return "xmark.circle"
|
||||
default: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var deliveryDisplay: String? {
|
||||
guard let deliver, !deliver.isEmpty else { return nil }
|
||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||
if deliver.hasPrefix("discord:") {
|
||||
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
if parts.count == 2 {
|
||||
return "Discord thread \(parts[1]) in \(parts[0])"
|
||||
}
|
||||
if parts.count == 1 {
|
||||
return "Discord \(parts[0])"
|
||||
}
|
||||
}
|
||||
return deliver
|
||||
}
|
||||
}
|
||||
|
||||
struct CronSchedule: Sendable, Codable {
|
||||
nonisolated let kind: String
|
||||
nonisolated let runAt: String?
|
||||
nonisolated let display: String?
|
||||
nonisolated let expression: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case runAt = "run_at"
|
||||
case display
|
||||
case expression
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.kind = try c.decode(String.self, forKey: .kind)
|
||||
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
||||
self.display = try c.decodeIfPresent(String.self, forKey: .display)
|
||||
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(kind, forKey: .kind)
|
||||
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||
try c.encodeIfPresent(display, forKey: .display)
|
||||
try c.encodeIfPresent(expression, forKey: .expression)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||
// is nonisolated and needs to decode this from a background task.
|
||||
struct CronJobsFile: Sendable, Codable {
|
||||
nonisolated let jobs: [HermesCronJob]
|
||||
nonisolated let updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
nonisolated func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(jobs, forKey: .jobs)
|
||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||
case stdio
|
||||
case http
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stdio: return "Local (stdio)"
|
||||
case .http: return "Remote (HTTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
||||
let name: String
|
||||
let transport: MCPTransport
|
||||
let command: String?
|
||||
let args: [String]
|
||||
let url: String?
|
||||
let auth: String?
|
||||
let env: [String: String]
|
||||
let headers: [String: String]
|
||||
let timeout: Int?
|
||||
let connectTimeout: Int?
|
||||
let enabled: Bool
|
||||
let toolsInclude: [String]
|
||||
let toolsExclude: [String]
|
||||
let resourcesEnabled: Bool
|
||||
let promptsEnabled: Bool
|
||||
let hasOAuthToken: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var summary: String {
|
||||
switch transport {
|
||||
case .stdio:
|
||||
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
||||
return (command ?? "") + argString
|
||||
case .http:
|
||||
return url ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MCPTestResult: Sendable, Equatable {
|
||||
let serverName: String
|
||||
let succeeded: Bool
|
||||
let output: String
|
||||
let tools: [String]
|
||||
let elapsed: TimeInterval
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesMessage: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let sessionId: String
|
||||
let role: String
|
||||
let content: String
|
||||
let toolCallId: String?
|
||||
let toolCalls: [HermesToolCall]
|
||||
let toolName: String?
|
||||
let timestamp: Date?
|
||||
let tokenCount: Int?
|
||||
let finishReason: String?
|
||||
let reasoning: String?
|
||||
|
||||
var isUser: Bool { role == "user" }
|
||||
var isAssistant: Bool { role == "assistant" }
|
||||
var isToolResult: Bool { role == "tool" }
|
||||
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||
}
|
||||
|
||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
var id: String { callId }
|
||||
let callId: String
|
||||
let functionName: String
|
||||
let arguments: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case callId = "id"
|
||||
case type
|
||||
case function
|
||||
}
|
||||
|
||||
enum FunctionKeys: String, CodingKey {
|
||||
case name
|
||||
case arguments
|
||||
}
|
||||
|
||||
init(callId: String, functionName: String, arguments: String) {
|
||||
self.callId = callId
|
||||
self.functionName = functionName
|
||||
self.arguments = arguments
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
callId = try container.decode(String.self, forKey: .callId)
|
||||
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||
functionName = try funcContainer.decode(String.self, forKey: .name)
|
||||
arguments = try funcContainer.decode(String.self, forKey: .arguments)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(callId, forKey: .callId)
|
||||
try container.encode("function", forKey: .type)
|
||||
var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||
try funcContainer.encode(functionName, forKey: .name)
|
||||
try funcContainer.encode(arguments, forKey: .arguments)
|
||||
}
|
||||
|
||||
var toolKind: ToolKind {
|
||||
switch functionName {
|
||||
case "read_file", "search_files", "vision_analyze": return .read
|
||||
case "write_file", "patch": return .edit
|
||||
case "terminal", "execute_code": return .execute
|
||||
case "web_search", "web_extract": return .fetch
|
||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
var argumentsSummary: String {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return arguments
|
||||
}
|
||||
if let command = json["command"] as? String {
|
||||
return command
|
||||
}
|
||||
if let path = json["path"] as? String {
|
||||
return path
|
||||
}
|
||||
if let query = json["query"] as? String {
|
||||
return query
|
||||
}
|
||||
if let url = json["url"] as? String {
|
||||
return url
|
||||
}
|
||||
return arguments.prefix(120) + (arguments.count > 120 ? "..." : "")
|
||||
}
|
||||
}
|
||||
|
||||
enum ToolKind: String, Sendable, CaseIterable {
|
||||
case read
|
||||
case edit
|
||||
case execute
|
||||
case fetch
|
||||
case browser
|
||||
case other
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .read: return "doc.text.magnifyingglass"
|
||||
case .edit: return "pencil"
|
||||
case .execute: return "terminal"
|
||||
case .fetch: return "globe"
|
||||
case .browser: return "safari"
|
||||
case .other: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .read: return "green"
|
||||
case .edit: return "blue"
|
||||
case .execute: return "orange"
|
||||
case .fetch: return "purple"
|
||||
case .browser: return "indigo"
|
||||
case .other: return "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
/// The filesystem layout of a Hermes installation, parameterized by the
|
||||
/// `home` directory. The same layout is used for local installations (where
|
||||
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
|
||||
/// remote installations reached over SSH (where `home` is a remote path like
|
||||
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
|
||||
/// will resolve).
|
||||
///
|
||||
/// Every path that used to live as a module-level static on `HermesPaths` is
|
||||
/// an instance property here. `ServerContext.paths` is the canonical way to
|
||||
/// reach these values; the old `HermesPaths` statics are preserved as
|
||||
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
||||
struct HermesPathSet: Sendable, Hashable {
|
||||
let home: String
|
||||
/// `true` when this path set belongs to a remote installation. Affects
|
||||
/// only `hermesBinary` resolution — every other path is identical in
|
||||
/// shape between local and remote.
|
||||
let isRemote: Bool
|
||||
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
||||
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
||||
/// target host. Unused when `isRemote == false`.
|
||||
let binaryHint: String?
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
/// Absolute path to the local user's `~/.hermes` directory.
|
||||
nonisolated static let defaultLocalHome: String = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return user + "/.hermes"
|
||||
}()
|
||||
|
||||
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||
nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||
|
||||
// MARK: - Paths (mirror of the old HermesPaths layout)
|
||||
|
||||
nonisolated var stateDB: String { home + "/state.db" }
|
||||
nonisolated var configYAML: String { home + "/config.yaml" }
|
||||
nonisolated var envFile: String { home + "/.env" }
|
||||
nonisolated var authJSON: String { home + "/auth.json" }
|
||||
nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||
nonisolated var pluginsDir: String { home + "/plugins" }
|
||||
nonisolated var memoriesDir: String { home + "/memories" }
|
||||
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||
nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||
nonisolated var sessionsDir: String { home + "/sessions" }
|
||||
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||
nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||
nonisolated var skillsDir: String { home + "/skills" }
|
||||
nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||
nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
nonisolated var scarfDir: String { home + "/scarf" }
|
||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
/// Install locations we probe for the local `hermes` binary, in priority
|
||||
/// order. Checked on every access so a user installing via a different
|
||||
/// method doesn't need to relaunch Scarf.
|
||||
nonisolated static let hermesBinaryCandidates: [String] = {
|
||||
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||
return [
|
||||
user + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||
user + "/.hermes/bin/hermes" // Some self-install layouts
|
||||
]
|
||||
}()
|
||||
|
||||
/// Resolved path to the `hermes` executable for this installation.
|
||||
///
|
||||
/// Local: returns the first executable candidate, falling back to the
|
||||
/// pipx default so error messages still make sense on a fresh machine.
|
||||
///
|
||||
/// Remote: returns `binaryHint` (populated at connect time) or bare
|
||||
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
||||
nonisolated var hermesBinary: String {
|
||||
if isRemote {
|
||||
return binaryHint ?? "hermes"
|
||||
}
|
||||
for path in Self.hermesBinaryCandidates
|
||||
where FileManager.default.isExecutableFile(atPath: path) {
|
||||
return path
|
||||
}
|
||||
return Self.hermesBinaryCandidates[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesSession: Identifiable, Sendable {
|
||||
let id: String
|
||||
let source: String
|
||||
let userId: String?
|
||||
let model: String?
|
||||
let title: String?
|
||||
let parentSessionId: String?
|
||||
let startedAt: Date?
|
||||
let endedAt: Date?
|
||||
let endReason: String?
|
||||
let messageCount: Int
|
||||
let toolCallCount: Int
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let estimatedCostUSD: Double?
|
||||
let reasoningTokens: Int
|
||||
let actualCostUSD: Double?
|
||||
let costStatus: String?
|
||||
let billingProvider: String?
|
||||
|
||||
var isSubagent: Bool { parentSessionId != nil }
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||
|
||||
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||
|
||||
var costIsActual: Bool { actualCostUSD != nil }
|
||||
|
||||
var duration: TimeInterval? {
|
||||
guard let start = startedAt, let end = endedAt else { return nil }
|
||||
return end.timeIntervalSince(start)
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
title ?? id
|
||||
}
|
||||
|
||||
var sourceIcon: String {
|
||||
KnownPlatforms.icon(for: source)
|
||||
}
|
||||
|
||||
func withTitle(_ newTitle: String) -> HermesSession {
|
||||
HermesSession(
|
||||
id: id, source: source, userId: userId, model: model,
|
||||
title: newTitle, parentSessionId: parentSessionId,
|
||||
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||
billingProvider: billingProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesSkillCategory: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let skills: [HermesSkill]
|
||||
}
|
||||
|
||||
struct HermesSkill: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let category: String
|
||||
let path: String
|
||||
let files: [String]
|
||||
let requiredConfig: [String]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesToolset: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let description: String
|
||||
let icon: String
|
||||
var enabled: Bool
|
||||
}
|
||||
|
||||
struct HermesToolPlatform: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let displayName: String
|
||||
let icon: String
|
||||
}
|
||||
|
||||
enum KnownPlatforms {
|
||||
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||
static let all: [HermesToolPlatform] = [
|
||||
cli,
|
||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
||||
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
|
||||
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
|
||||
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
||||
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"),
|
||||
]
|
||||
|
||||
static func icon(for platform: String) -> String {
|
||||
switch platform {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "whatsapp": return "phone.bubble"
|
||||
case "signal": return "lock.shield"
|
||||
case "email": return "envelope"
|
||||
case "homeassistant": return "house"
|
||||
case "webhook": return "arrow.up.right.square"
|
||||
case "matrix": return "lock.rectangle.stack"
|
||||
case "feishu": return "message.badge.circle"
|
||||
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let description: String
|
||||
let category: String
|
||||
let iconSystemName: String
|
||||
let transport: MCPTransport
|
||||
let command: String?
|
||||
let args: [String]
|
||||
let url: String?
|
||||
let auth: String?
|
||||
let requiredEnvKeys: [String]
|
||||
let optionalEnvKeys: [String]
|
||||
let pathArgPrompt: String?
|
||||
let docsURL: String
|
||||
|
||||
static let gallery: [MCPServerPreset] = [
|
||||
MCPServerPreset(
|
||||
id: "filesystem",
|
||||
displayName: "Filesystem",
|
||||
description: "Read and write files under a root directory you choose.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "folder",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: "Root directory (absolute path)",
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
description: "Issues, pull requests, code search, and file operations via GitHub API.",
|
||||
category: "Dev",
|
||||
iconSystemName: "chevron.left.forwardslash.chevron.right",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "postgres",
|
||||
displayName: "Postgres",
|
||||
description: "Read-only SQL access against a Postgres database.",
|
||||
category: "Data",
|
||||
iconSystemName: "cylinder.split.1x2",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-postgres"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "slack",
|
||||
displayName: "Slack",
|
||||
description: "Read channels, post messages, and search your Slack workspace.",
|
||||
category: "Productivity",
|
||||
iconSystemName: "bubble.left.and.bubble.right",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-slack"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "linear",
|
||||
displayName: "Linear",
|
||||
description: "Query and update Linear issues. Uses OAuth — no token needed.",
|
||||
category: "Productivity",
|
||||
iconSystemName: "list.bullet.rectangle",
|
||||
transport: .http,
|
||||
command: nil,
|
||||
args: [],
|
||||
url: "https://mcp.linear.app/sse",
|
||||
auth: "oauth",
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://linear.app/docs/mcp"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "sentry",
|
||||
displayName: "Sentry",
|
||||
description: "Investigate errors and performance issues from Sentry.",
|
||||
category: "Dev",
|
||||
iconSystemName: "exclamationmark.triangle",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@sentry/mcp-server"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://docs.sentry.io/product/mcp/"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "puppeteer",
|
||||
displayName: "Puppeteer",
|
||||
description: "Headless browser automation — navigate pages, click, screenshot.",
|
||||
category: "Automation",
|
||||
iconSystemName: "safari",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "memory",
|
||||
displayName: "Memory (Knowledge Graph)",
|
||||
description: "Persistent knowledge graph of entities and relations across sessions.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "brain",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-memory"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: ["MEMORY_FILE_PATH"],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "fetch",
|
||||
displayName: "Fetch",
|
||||
description: "Retrieve and convert web pages to markdown.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "arrow.down.circle",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
|
||||
)
|
||||
]
|
||||
|
||||
static var categories: [String] {
|
||||
var seen = Set<String>()
|
||||
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
||||
}
|
||||
|
||||
static func byCategory(_ category: String) -> [MCPServerPreset] {
|
||||
gallery.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Registry
|
||||
|
||||
struct ProjectRegistry: Codable, Sendable {
|
||||
var projects: [ProjectEntry]
|
||||
}
|
||||
|
||||
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let path: String
|
||||
|
||||
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
struct ProjectDashboard: Codable, Sendable {
|
||||
let version: Int
|
||||
let title: String
|
||||
let description: String?
|
||||
let updatedAt: String?
|
||||
let theme: DashboardTheme?
|
||||
let sections: [DashboardSection]
|
||||
}
|
||||
|
||||
struct DashboardTheme: Codable, Sendable {
|
||||
let accent: String?
|
||||
}
|
||||
|
||||
struct DashboardSection: Codable, Sendable, Identifiable {
|
||||
var id: String { title }
|
||||
let title: String
|
||||
let columns: Int?
|
||||
let widgets: [DashboardWidget]
|
||||
|
||||
var columnCount: Int { columns ?? 3 }
|
||||
}
|
||||
|
||||
struct DashboardWidget: Codable, Sendable, Identifiable {
|
||||
var id: String { type + ":" + title }
|
||||
|
||||
let type: String
|
||||
let title: String
|
||||
|
||||
// Stat
|
||||
let value: WidgetValue?
|
||||
let icon: String?
|
||||
let color: String?
|
||||
let subtitle: String?
|
||||
|
||||
// Progress
|
||||
let label: String?
|
||||
|
||||
// Text
|
||||
let content: String?
|
||||
let format: String?
|
||||
|
||||
// Table
|
||||
let columns: [String]?
|
||||
let rows: [[String]]?
|
||||
|
||||
// Chart
|
||||
let chartType: String?
|
||||
let xLabel: String?
|
||||
let yLabel: String?
|
||||
let series: [ChartSeries]?
|
||||
|
||||
// List
|
||||
let items: [ListItem]?
|
||||
|
||||
// Webview
|
||||
let url: String?
|
||||
let height: Double?
|
||||
}
|
||||
|
||||
// MARK: - Widget Value (String or Number)
|
||||
|
||||
enum WidgetValue: Codable, Sendable, Hashable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
|
||||
var displayString: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(format: "%.1f", n)
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let d = try? container.decode(Double.self) {
|
||||
self = .number(d)
|
||||
} else if let s = try? container.decode(String.self) {
|
||||
self = .string(s)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
WidgetValue.self,
|
||||
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chart Data
|
||||
|
||||
struct ChartSeries: Codable, Sendable, Identifiable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let color: String?
|
||||
let data: [ChartDataPoint]
|
||||
}
|
||||
|
||||
struct ChartDataPoint: Codable, Sendable, Identifiable {
|
||||
var id: String { x }
|
||||
let x: String
|
||||
let y: Double
|
||||
}
|
||||
|
||||
// MARK: - List Data
|
||||
|
||||
struct ListItem: Codable, Sendable, Identifiable {
|
||||
var id: String { text }
|
||||
let text: String
|
||||
let status: String?
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Stable identifier for a server entry in the user's registry. Backed by
|
||||
/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
|
||||
/// restoration without collisions.
|
||||
typealias ServerID = UUID
|
||||
|
||||
/// Connection parameters for a remote Hermes installation reached over SSH.
|
||||
/// All fields are optional except `host` — unset values defer to the user's
|
||||
/// `~/.ssh/config` and the OpenSSH defaults.
|
||||
struct SSHConfig: Sendable, Hashable, Codable {
|
||||
/// Hostname or `~/.ssh/config` alias.
|
||||
var host: String
|
||||
/// Remote username. `nil` → defer to `~/.ssh/config` or the local user.
|
||||
var user: String?
|
||||
/// TCP port. `nil` → 22 (or whatever `~/.ssh/config` says).
|
||||
var port: Int?
|
||||
/// Absolute path to a private key. `nil` → defer to ssh-agent /
|
||||
/// `~/.ssh/config` identity files.
|
||||
var identityFile: String?
|
||||
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
|
||||
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
|
||||
/// remote side).
|
||||
var remoteHome: 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.
|
||||
var hermesBinaryHint: String?
|
||||
}
|
||||
|
||||
/// Distinguishes a local installation (the user's own `~/.hermes`) from a
|
||||
/// remote one reached over SSH. Service behavior is identical in shape but
|
||||
/// dispatches to different I/O primitives in Phase 2.
|
||||
enum ServerKind: Sendable, Hashable, Codable {
|
||||
case local
|
||||
case ssh(SSHConfig)
|
||||
}
|
||||
|
||||
/// The per-server value that flows through `.environment` and gets handed to
|
||||
/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to
|
||||
/// one Hermes installation; multi-window scenes in Phase 3 will construct
|
||||
/// one per window.
|
||||
///
|
||||
/// **Why every member is `nonisolated`.** This file imports `AppKit`
|
||||
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
|
||||
/// upcoming default-isolation rules pulls the whole struct to `@MainActor`.
|
||||
/// `ServerContext` is a plain `Sendable` value — accessing `.local`, `.paths`,
|
||||
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
|
||||
/// the caller into hopping MainActor. `nonisolated` on each member keeps
|
||||
/// them callable from any context; the one MainActor-dependent method
|
||||
/// (`openInLocalEditor`) lives in the extension below.
|
||||
struct ServerContext: Sendable, Hashable, Identifiable {
|
||||
let id: ServerID
|
||||
var displayName: String
|
||||
var kind: ServerKind
|
||||
|
||||
/// Path layout for this server. Cheap — all path components are computed
|
||||
/// on demand from `home`, no I/O.
|
||||
nonisolated var paths: HermesPathSet {
|
||||
switch kind {
|
||||
case .local:
|
||||
return HermesPathSet(
|
||||
home: HermesPathSet.defaultLocalHome,
|
||||
isRemote: false,
|
||||
binaryHint: nil
|
||||
)
|
||||
case .ssh(let config):
|
||||
return HermesPathSet(
|
||||
home: config.remoteHome ?? HermesPathSet.defaultRemoteHome,
|
||||
isRemote: true,
|
||||
binaryHint: config.hermesBinaryHint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated var isRemote: Bool {
|
||||
if case .ssh = kind { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Construct the `ServerTransport` for this context. Local contexts get
|
||||
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
|
||||
/// from `SSHConfig`. Each call returns a fresh value — transports are
|
||||
/// cheap and stateless beyond disk caches.
|
||||
nonisolated func makeTransport() -> any ServerTransport {
|
||||
switch kind {
|
||||
case .local:
|
||||
return LocalTransport(contextID: id)
|
||||
case .ssh(let config):
|
||||
return SSHTransport(contextID: id, config: config, displayName: displayName)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Well-known singletons
|
||||
|
||||
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
|
||||
/// local context has the same identity across launches, and so persisted
|
||||
/// window-state restorations that reference it continue to resolve even
|
||||
/// if `servers.json` hasn't been touched yet.
|
||||
nonisolated private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||
|
||||
/// The default "this machine" context. Used everywhere in Phase 0/1 and
|
||||
/// remains the fallback when no remote server is selected.
|
||||
nonisolated static let local = ServerContext(
|
||||
id: localID,
|
||||
displayName: "Local",
|
||||
kind: .local
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Remote user-home resolution
|
||||
|
||||
/// Process-wide cache of each server's resolved user `$HOME`. Probed once per
|
||||
/// `ServerID` via the transport, then memoized for the app's lifetime — home
|
||||
/// directories don't change under us, and the probe is a ~5ms SSH round-trip
|
||||
/// with ControlMaster. Used by anything that needs to hand a working
|
||||
/// directory to the ACP agent or the Hermes CLI on the correct host.
|
||||
private actor UserHomeCache {
|
||||
static let shared = UserHomeCache()
|
||||
private var cache: [ServerID: String] = [:]
|
||||
|
||||
func resolve(for context: ServerContext) async -> String {
|
||||
if let cached = cache[context.id] { return cached }
|
||||
let resolved = await probe(context: context)
|
||||
cache[context.id] = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
func invalidate(contextID: ServerID) {
|
||||
cache.removeValue(forKey: contextID)
|
||||
}
|
||||
|
||||
private func probe(context: ServerContext) async -> String {
|
||||
if !context.isRemote { return NSHomeDirectory() }
|
||||
let transport = context.makeTransport()
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/bin/sh",
|
||||
args: ["-c", "echo $HOME"],
|
||||
stdin: nil,
|
||||
timeout: 10
|
||||
)
|
||||
let out = result?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
// Fall back to `~` (unexpanded) so ACP at least gets a plausible cwd
|
||||
// rather than a local Mac path. The remote side will expand it if
|
||||
// passed through a shell; if not, failures are surfaced by ACP itself.
|
||||
return out.isEmpty ? "~" : out
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerContext {
|
||||
/// Resolved absolute path to the user's home directory on the target host.
|
||||
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
|
||||
/// Use this — not `NSHomeDirectory()` — whenever you're passing a `cwd`
|
||||
/// or user path to a process that runs on the target host.
|
||||
func resolvedUserHome() async -> String {
|
||||
await UserHomeCache.shared.resolve(for: self)
|
||||
}
|
||||
|
||||
/// Called when a server is removed from the registry, so the process-wide
|
||||
/// caches keyed by `ServerID` don't hold stale entries forever.
|
||||
static func invalidateCaches(for contextID: ServerID) async {
|
||||
await UserHomeCache.shared.invalidate(contextID: contextID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience file I/O via the right transport
|
||||
|
||||
/// Centralized file I/O entry points for VMs that don't own a service. Every
|
||||
/// call goes through the context's transport, so reads/writes hit the local
|
||||
/// disk for `.local` and ssh/scp for `.ssh` automatically.
|
||||
///
|
||||
/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)`
|
||||
/// when the path comes from `context.paths`. The Foundation file APIs are
|
||||
/// LOCAL ONLY — using them with a remote path silently returns nil because
|
||||
/// the remote path doesn't exist on this Mac.
|
||||
extension ServerContext {
|
||||
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
|
||||
/// invalid encoding).
|
||||
nonisolated func readText(_ path: String) -> String? {
|
||||
guard let data = try? makeTransport().readFile(path) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Read raw bytes. `nil` on any error.
|
||||
nonisolated func readData(_ path: String) -> Data? {
|
||||
try? makeTransport().readFile(path)
|
||||
}
|
||||
|
||||
/// Atomic write. Returns `true` on success, `false` on any error
|
||||
/// (caller is expected to surface failures via UI when relevant).
|
||||
@discardableResult
|
||||
nonisolated func writeText(_ path: String, content: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else { return false }
|
||||
do {
|
||||
try makeTransport().writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
|
||||
nonisolated func fileExists(_ path: String) -> Bool {
|
||||
makeTransport().fileExists(path)
|
||||
}
|
||||
|
||||
/// File modification timestamp, or `nil` if the file doesn't exist.
|
||||
nonisolated func modificationDate(_ path: String) -> Date? {
|
||||
makeTransport().stat(path)?.mtime
|
||||
}
|
||||
|
||||
/// Invoke the `hermes` CLI on this server and return its combined output
|
||||
/// + exit code. Local: spawns the local binary via `Process`. Remote:
|
||||
/// rounds through `ssh host hermes …`. Use this from any VM that needs
|
||||
/// to fire off a CLI command — never spawn `hermes` via `Process()`
|
||||
/// directly, because that path bypasses the transport for remote.
|
||||
@discardableResult
|
||||
nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) {
|
||||
let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin)
|
||||
return (result.output, result.exitCode)
|
||||
}
|
||||
|
||||
/// Reveal the file at `path` in the user's local editor (via
|
||||
/// `NSWorkspace.open`). For remote contexts this is a no-op — the
|
||||
/// file doesn't exist on this Mac, so opening it would fail silently
|
||||
/// or worse, open the wrong file from the local filesystem.
|
||||
/// Returns `true` if opened, `false` if the call was skipped.
|
||||
@discardableResult
|
||||
func openInLocalEditor(_ path: String) -> Bool {
|
||||
guard !isRemote else { return false }
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI environment plumbing
|
||||
|
||||
/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which
|
||||
/// requires an `@Observable` class) doesn't accept it directly. We expose it
|
||||
/// through a custom `EnvironmentKey` — views read it with
|
||||
/// `@Environment(\.serverContext) private var serverContext`.
|
||||
private struct ServerContextEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue: ServerContext = .local
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var serverContext: ServerContext {
|
||||
get { self[ServerContextEnvironmentKey.self] }
|
||||
set { self[ServerContextEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Persisted entry for a user-added server. `ServerContext` itself is a value
|
||||
/// type we rebuild from these fields at runtime — we persist the minimum that
|
||||
/// uniquely identifies a connection, not the whole context struct, so future
|
||||
/// fields we add to `ServerContext` don't force a migration.
|
||||
struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: ServerID
|
||||
var displayName: String
|
||||
var kind: ServerKind
|
||||
/// User preference: open this server in a window on launch. Phase 3
|
||||
/// multi-window uses this; Phase 2 ignores it.
|
||||
var openOnLaunch: Bool = false
|
||||
|
||||
var context: ServerContext {
|
||||
ServerContext(id: id, displayName: displayName, kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk envelope for `servers.json`. Schema-versioned so future changes
|
||||
/// can migrate without losing data.
|
||||
private struct RegistryFile: Codable {
|
||||
var schemaVersion: Int
|
||||
var entries: [ServerEntry]
|
||||
}
|
||||
|
||||
/// App-scoped store for user-added servers. `local` is synthesized (not
|
||||
/// persisted) and always appears first in `allContexts`. Remote entries are
|
||||
/// loaded from `~/Library/Application Support/scarf/servers.json`.
|
||||
///
|
||||
/// Observable so SwiftUI views binding to `entries` redraw when a server is
|
||||
/// added, renamed, or removed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ServerRegistry {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ServerRegistry")
|
||||
private static let currentSchemaVersion = 1
|
||||
|
||||
/// Remote (user-added) entries. Observable: views redraw on mutation.
|
||||
private(set) var entries: [ServerEntry] = []
|
||||
|
||||
private let storeURL: URL
|
||||
|
||||
init() {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support")
|
||||
let dir = support.appendingPathComponent("scarf", isDirectory: true)
|
||||
self.storeURL = dir.appendingPathComponent("servers.json")
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
/// The implicit local server plus every persisted remote entry, in list
|
||||
/// order. Use this when populating UI like the toolbar switcher.
|
||||
var allContexts: [ServerContext] {
|
||||
[.local] + entries.map { $0.context }
|
||||
}
|
||||
|
||||
/// Resolve an ID to a context, or `nil` if the entry no longer exists.
|
||||
/// Used by the multi-window root to detect "this window points at a
|
||||
/// server you've since removed" and show a dedicated empty state.
|
||||
func context(for id: ServerID) -> ServerContext? {
|
||||
if id == ServerContext.local.id { return .local }
|
||||
if let entry = entries.first(where: { $0.id == id }) {
|
||||
return entry.context
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Optional callback fired whenever `entries` changes. The app wires
|
||||
/// this to `ServerLiveStatusRegistry.rebuild()` so the menu-bar fanout
|
||||
/// stays in sync without polling the entries array.
|
||||
var onEntriesChanged: (() -> Void)?
|
||||
|
||||
@discardableResult
|
||||
func addServer(displayName: String, config: SSHConfig) -> ServerEntry {
|
||||
let entry = ServerEntry(
|
||||
id: ServerID(),
|
||||
displayName: displayName,
|
||||
kind: .ssh(config)
|
||||
)
|
||||
entries.append(entry)
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
return entry
|
||||
}
|
||||
|
||||
func updateServer(_ id: ServerID, displayName: String?, config: SSHConfig?) {
|
||||
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return }
|
||||
if let name = displayName { entries[idx].displayName = name }
|
||||
if let cfg = config { entries[idx].kind = .ssh(cfg) }
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
}
|
||||
|
||||
func removeServer(_ id: ServerID) {
|
||||
// Grab the entry BEFORE removing it so we can tear down its transport
|
||||
// state. Without this the user would leak a ControlMaster socket
|
||||
// (~10min TTL) and a snapshot cache dir (indefinite) per removed
|
||||
// server — harmless individually, ugly at scale.
|
||||
let removed = entries.first { $0.id == id }
|
||||
entries.removeAll { $0.id == id }
|
||||
save()
|
||||
|
||||
if let removed, case .ssh(let config) = removed.kind {
|
||||
let transport = SSHTransport(contextID: id, config: config, displayName: removed.displayName)
|
||||
transport.closeControlMaster()
|
||||
}
|
||||
SSHTransport.pruneSnapshotCache(for: id)
|
||||
// Drop process-wide cache entries keyed on this ServerID so a future
|
||||
// re-add with a colliding ID (theoretical — UUIDs are random, but be
|
||||
// defensive) doesn't serve stale data.
|
||||
Task.detached { await ServerContext.invalidateCaches(for: id) }
|
||||
|
||||
onEntriesChanged?()
|
||||
}
|
||||
|
||||
// MARK: - App-launch sweep
|
||||
|
||||
/// Remove snapshot cache directories whose UUID isn't in the current
|
||||
/// registry. Handles the case where the user removed a server while the
|
||||
/// app was closed — we want the cache to converge to the registry's
|
||||
/// state at launch rather than carrying forever.
|
||||
func sweepOrphanCaches() {
|
||||
var keep: Set<ServerID> = [ServerContext.local.id]
|
||||
for entry in entries { keep.insert(entry.id) }
|
||||
SSHTransport.sweepOrphanSnapshots(keeping: keep)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func load() {
|
||||
guard FileManager.default.fileExists(atPath: storeURL.path) else {
|
||||
entries = []
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: storeURL)
|
||||
let file = try JSONDecoder().decode(RegistryFile.self, from: data)
|
||||
entries = file.entries
|
||||
} catch {
|
||||
Self.logger.error("Failed to load servers.json: \(error.localizedDescription)")
|
||||
entries = []
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: storeURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let file = RegistryFile(schemaVersion: Self.currentSchemaVersion, entries: entries)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
try data.write(to: storeURL, options: .atomic)
|
||||
} catch {
|
||||
Self.logger.error("Failed to save servers.json: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
||||
/// Provides an async event stream for real-time session updates.
|
||||
actor ACPClient {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
private var stderrPipe: Pipe?
|
||||
private var stdinFd: Int32 = -1
|
||||
|
||||
private var nextRequestId = 1
|
||||
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
|
||||
private var readTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
|
||||
private var _eventStream: AsyncStream<ACPEvent>?
|
||||
|
||||
private(set) var isConnected = false
|
||||
private(set) var currentSessionId: String?
|
||||
private(set) var statusMessage = ""
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Ring buffer of recent stderr lines from `hermes acp` — used to attach
|
||||
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
|
||||
/// growth when the subprocess logs heavily.
|
||||
private var stderrBuffer: [String] = []
|
||||
private static let stderrBufferMaxLines = 50
|
||||
|
||||
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
|
||||
/// `hermes acp` subprocess, joined by newlines.
|
||||
var recentStderr: String {
|
||||
stderrBuffer.joined(separator: "\n")
|
||||
}
|
||||
|
||||
fileprivate func appendStderr(_ text: String) {
|
||||
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
stderrBuffer.append(String(line))
|
||||
}
|
||||
if stderrBuffer.count > Self.stderrBufferMaxLines {
|
||||
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the underlying process is still alive and connected.
|
||||
var isHealthy: Bool {
|
||||
guard isConnected, let process else { return false }
|
||||
return process.isRunning
|
||||
}
|
||||
|
||||
// MARK: - Event Stream
|
||||
|
||||
/// Access the event stream. Must call `start()` first.
|
||||
var events: AsyncStream<ACPEvent> {
|
||||
guard let stream = _eventStream else {
|
||||
// Return an empty stream if not started
|
||||
return AsyncStream { $0.finish() }
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() async throws {
|
||||
guard process == nil else { return }
|
||||
|
||||
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
|
||||
// Create the event stream BEFORE anything else so no events are lost
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
|
||||
self._eventStream = stream
|
||||
self.eventContinuation = continuation
|
||||
|
||||
// For local: Process is `hermes acp` directly.
|
||||
// For remote: the transport returns a Process configured as
|
||||
// `/usr/bin/ssh -T <opts> host -- <hermes> acp`. ACP's JSON-RPC
|
||||
// over stdio works identically because `-T` keeps the ssh channel
|
||||
// byte-clean and stdin/stdout travel end-to-end unmodified.
|
||||
let proc = transport.makeProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: ["acp"]
|
||||
)
|
||||
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
|
||||
proc.standardInput = stdin
|
||||
proc.standardOutput = stdout
|
||||
proc.standardError = stderr
|
||||
|
||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution.
|
||||
if context.isRemote {
|
||||
// Remote: this is the LOCAL ssh process spawning `ssh host …
|
||||
// hermes acp`. We don't forward our local PATH/credentials to
|
||||
// the remote (hermes runs under the remote user's login env),
|
||||
// but the ssh binary itself needs SSH_AUTH_SOCK to reach the
|
||||
// local ssh-agent for key-based auth.
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let shellEnv = HermesFileService.enrichedEnvironment()
|
||||
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
|
||||
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
|
||||
env[key] = v
|
||||
}
|
||||
}
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
} else {
|
||||
// Local: enriched env so any tools hermes spawns (MCP servers,
|
||||
// shell commands) can find brew/nvm/asdf binaries on PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] proc in
|
||||
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
||||
}
|
||||
|
||||
statusMessage = "Starting hermes acp..."
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
statusMessage = "Failed to start: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
|
||||
continuation.finish()
|
||||
throw error
|
||||
}
|
||||
|
||||
self.process = proc
|
||||
self.stdinPipe = stdin
|
||||
self.stdoutPipe = stdout
|
||||
self.stderrPipe = stderr
|
||||
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
|
||||
self.isConnected = true
|
||||
|
||||
// Start reading stdout BEFORE sending initialize (so we catch the response)
|
||||
startReadLoop(stdout: stdout, stderr: stderr)
|
||||
logger.info("hermes acp process started (pid: \(proc.processIdentifier))")
|
||||
statusMessage = "Initializing..."
|
||||
|
||||
// Initialize the ACP connection
|
||||
let initParams: [String: AnyCodable] = [
|
||||
"protocolVersion": AnyCodable(1),
|
||||
"clientCapabilities": AnyCodable([String: Any]()),
|
||||
"clientInfo": AnyCodable([
|
||||
"name": "Scarf",
|
||||
"version": "1.0"
|
||||
] as [String: Any])
|
||||
]
|
||||
_ = try await sendRequest(method: "initialize", params: initParams)
|
||||
statusMessage = "Connected"
|
||||
logger.info("ACP connection initialized")
|
||||
startKeepalive()
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
readTask?.cancel()
|
||||
readTask = nil
|
||||
stderrTask?.cancel()
|
||||
stderrTask = nil
|
||||
keepaliveTask?.cancel()
|
||||
keepaliveTask = nil
|
||||
eventContinuation?.finish()
|
||||
eventContinuation = nil
|
||||
_eventStream = nil
|
||||
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
|
||||
// Close stdin first so the subprocess sees EOF and can shut down gracefully
|
||||
stdinPipe?.fileHandleForWriting.closeFile()
|
||||
|
||||
if let process, process.isRunning {
|
||||
// SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly)
|
||||
process.interrupt()
|
||||
// Watchdog: force-kill if still running after 2 seconds
|
||||
let watchdogProcess = process
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if watchdogProcess.isRunning {
|
||||
watchdogProcess.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
stdinPipe?.fileHandleForReading.closeFile()
|
||||
stdoutPipe?.fileHandleForReading.closeFile()
|
||||
stderrPipe?.fileHandleForReading.closeFile()
|
||||
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
stderrPipe = nil
|
||||
stdinFd = -1
|
||||
isConnected = false
|
||||
currentSessionId = nil
|
||||
statusMessage = "Disconnected"
|
||||
logger.info("ACP client stopped")
|
||||
}
|
||||
|
||||
// MARK: - Keepalive
|
||||
|
||||
private func startKeepalive() {
|
||||
keepaliveTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
|
||||
guard !Task.isCancelled else { break }
|
||||
await self?.sendKeepalive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid JSON-RPC notification used as a keepalive probe.
|
||||
/// Sending bare newlines causes `json.loads("")` errors in the ACP library.
|
||||
private static let keepalivePayload: Data = {
|
||||
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n"
|
||||
return Data(json.utf8)
|
||||
}()
|
||||
|
||||
private func sendKeepalive() {
|
||||
let fd = stdinFd
|
||||
guard fd >= 0 else { return }
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func newSession(cwd: String) async throws -> String {
|
||||
statusMessage = "Creating session..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/new", params: params)
|
||||
guard let dict = result?.dictValue,
|
||||
let sessionId = dict["sessionId"] as? String else {
|
||||
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
|
||||
}
|
||||
currentSessionId = sessionId
|
||||
statusMessage = "Session ready"
|
||||
logger.info("Created new ACP session: \(sessionId)")
|
||||
return sessionId
|
||||
}
|
||||
|
||||
func loadSession(cwd: String, sessionId: String) async throws -> String {
|
||||
statusMessage = "Loading session \(sessionId.prefix(12))..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/load", params: params)
|
||||
// ACP returns {} on success (no sessionId echoed), or an error if not found.
|
||||
// If we got here without throwing, the session was loaded. Use the ID we sent.
|
||||
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
|
||||
currentSessionId = loadedId
|
||||
statusMessage = "Session loaded"
|
||||
logger.info("Loaded ACP session: \(loadedId)")
|
||||
return loadedId
|
||||
}
|
||||
|
||||
func resumeSession(cwd: String, sessionId: String) async throws -> String {
|
||||
statusMessage = "Resuming session..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/resume", params: params)
|
||||
guard let dict = result?.dictValue,
|
||||
let resumedId = dict["sessionId"] as? String else {
|
||||
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
|
||||
}
|
||||
currentSessionId = resumedId
|
||||
statusMessage = "Session resumed"
|
||||
logger.info("Resumed ACP session: \(resumedId)")
|
||||
return resumedId
|
||||
}
|
||||
|
||||
// MARK: - Messaging
|
||||
|
||||
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
||||
statusMessage = "Sending prompt..."
|
||||
let messageId = UUID().uuidString
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"messageId": AnyCodable(messageId),
|
||||
"prompt": AnyCodable([
|
||||
["type": "text", "text": text] as [String: Any]
|
||||
] as [Any])
|
||||
]
|
||||
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||
let dict = result?.dictValue ?? [:]
|
||||
let usage = dict["usage"] as? [String: Any] ?? [:]
|
||||
|
||||
statusMessage = "Ready"
|
||||
return ACPPromptResult(
|
||||
stopReason: dict["stopReason"] as? String ?? "end_turn",
|
||||
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
func cancel(sessionId: String) async throws {
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(sessionId)
|
||||
]
|
||||
_ = try await sendRequest(method: "session/cancel", params: params)
|
||||
statusMessage = "Cancelled"
|
||||
}
|
||||
|
||||
func respondToPermission(requestId: Int, optionId: String) {
|
||||
let response: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": requestId,
|
||||
"result": [
|
||||
"outcome": [
|
||||
"kind": optionId == "deny" ? "rejected" : "allowed",
|
||||
"optionId": optionId
|
||||
] as [String: Any]
|
||||
] as [String: Any]
|
||||
]
|
||||
writeJSON(response)
|
||||
}
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
|
||||
let requestId = nextRequestId
|
||||
nextRequestId += 1
|
||||
|
||||
let request = ACPRequest(id: requestId, method: method, params: params)
|
||||
|
||||
guard let data = try? JSONEncoder().encode(request) else {
|
||||
throw ACPClientError.encodingFailed
|
||||
}
|
||||
|
||||
logger.debug("Sending: \(method) (id: \(requestId))")
|
||||
|
||||
// session/prompt streams events and can run for minutes — no hard timeout.
|
||||
// Control messages get a 30s watchdog.
|
||||
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||
Task { [weak self] in
|
||||
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||
await self?.timeoutRequest(id: requestId, method: method)
|
||||
}
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
defer { timeoutTask?.cancel() }
|
||||
|
||||
let fd = stdinFd
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
|
||||
pendingRequests[requestId] = continuation
|
||||
|
||||
guard fd >= 0 else {
|
||||
pendingRequests.removeValue(forKey: requestId)
|
||||
continuation.resume(throwing: ACPClientError.notConnected)
|
||||
return
|
||||
}
|
||||
|
||||
var payload = data
|
||||
payload.append(contentsOf: "\n".utf8)
|
||||
// Write in a detached task to avoid blocking the actor's executor.
|
||||
// The continuation is already stored; the response arrives via the read loop.
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailedForRequest(id: requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRequest(id: Int, method: String) {
|
||||
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
|
||||
logger.error("Request timed out: \(method) (id: \(id))")
|
||||
statusMessage = "Request timed out"
|
||||
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
|
||||
}
|
||||
|
||||
private func writeJSON(_ dict: [String: Any]) {
|
||||
let fd = stdinFd
|
||||
guard fd >= 0,
|
||||
let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
|
||||
var payload = data
|
||||
payload.append(contentsOf: "\n".utf8)
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read Loop
|
||||
|
||||
private func startReadLoop(stdout: Pipe, stderr: Pipe) {
|
||||
// Read stdout for JSON-RPC messages
|
||||
readTask = Task.detached { [weak self] in
|
||||
let handle = stdout.fileHandleForReading
|
||||
var buffer = Data()
|
||||
|
||||
while !Task.isCancelled {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break } // EOF
|
||||
buffer.append(chunk)
|
||||
|
||||
while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let lineData = Data(buffer[buffer.startIndex..<newlineIndex])
|
||||
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
|
||||
|
||||
guard !lineData.isEmpty else { continue }
|
||||
|
||||
if let lineStr = String(data: lineData, encoding: .utf8) {
|
||||
self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||
}
|
||||
|
||||
do {
|
||||
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
|
||||
await self?.handleMessage(message)
|
||||
} catch {
|
||||
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
await self?.handleReadLoopEnded()
|
||||
}
|
||||
|
||||
// Read stderr in background for diagnostic logging AND ring-buffer
|
||||
// capture so we can attach a tail to user-visible errors.
|
||||
stderrTask = Task.detached { [weak self] in
|
||||
let handle = stderr.fileHandleForReading
|
||||
while !Task.isCancelled {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||
await self?.appendStderr(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: ACPRawMessage) {
|
||||
if message.isResponse {
|
||||
if let requestId = message.id,
|
||||
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||
if let error = message.error {
|
||||
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||
statusMessage = "Error: \(error.message)"
|
||||
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||
} else {
|
||||
logger.debug("ACP response (id: \(requestId))")
|
||||
continuation.resume(returning: message.result)
|
||||
}
|
||||
} else {
|
||||
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||
}
|
||||
} else if message.isNotification {
|
||||
if let event = ACPEventParser.parse(notification: message) {
|
||||
logger.debug("ACP event: \(String(describing: event).prefix(100))")
|
||||
eventContinuation?.yield(event)
|
||||
}
|
||||
} else if message.isRequest {
|
||||
if message.method == "session/request_permission",
|
||||
let event = ACPEventParser.parsePermissionRequest(message) {
|
||||
statusMessage = "Permission required"
|
||||
eventContinuation?.yield(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Disconnect Cleanup
|
||||
|
||||
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||
private func performDisconnectCleanup(reason: String) {
|
||||
guard isConnected else { return }
|
||||
logger.warning("ACP disconnecting: \(reason)")
|
||||
isConnected = false
|
||||
statusMessage = "Connection lost"
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
eventContinuation?.finish()
|
||||
eventContinuation = nil
|
||||
}
|
||||
|
||||
private func handleReadLoopEnded() {
|
||||
performDisconnectCleanup(reason: "read loop ended (EOF)")
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
performDisconnectCleanup(reason: "process exited (\(exitCode))")
|
||||
}
|
||||
|
||||
private func handleWriteFailed() {
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
|
||||
private func handleWriteFailedForRequest(id: Int) {
|
||||
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
}
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
|
||||
// MARK: - Safe POSIX Write
|
||||
|
||||
/// Write data to a file descriptor using POSIX write(), returning false on error.
|
||||
/// Handles partial writes and returns false on EPIPE or other errors.
|
||||
private static func safeWrite(fd: Int32, data: Data) -> Bool {
|
||||
data.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return false }
|
||||
var written = 0
|
||||
let total = buf.count
|
||||
while written < total {
|
||||
let result = Darwin.write(fd, base.advanced(by: written), total - written)
|
||||
if result <= 0 { return false }
|
||||
written += result
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ACPClientError: Error, LocalizedError {
|
||||
case notConnected
|
||||
case encodingFailed
|
||||
case invalidResponse(String)
|
||||
case rpcError(code: Int, message: String)
|
||||
case processTerminated
|
||||
case requestTimeout(method: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "ACP client is not connected"
|
||||
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 .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
enum ACPErrorHint {
|
||||
static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||
let haystack = errorMessage + "\n" + stderrTail
|
||||
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."
|
||||
}
|
||||
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||
options: .regularExpression) {
|
||||
let matched = String(haystack[match])
|
||||
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 "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 nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
||||
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
||||
/// can all ask for a fresh snapshot within the same millisecond — without
|
||||
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
|
||||
/// round-trip, three parallel backups of the same DB. Callers in flight for
|
||||
/// the same `ServerID` await the first caller's Task and share its result.
|
||||
actor SnapshotCoordinator {
|
||||
static let shared = SnapshotCoordinator()
|
||||
private var inFlight: [ServerID: Task<URL, Error>] = [:]
|
||||
|
||||
func snapshot(
|
||||
remotePath: String,
|
||||
contextID: ServerID,
|
||||
transport: any ServerTransport
|
||||
) async throws -> URL {
|
||||
if let existing = inFlight[contextID] {
|
||||
return try await existing.value
|
||||
}
|
||||
let task = Task<URL, Error> {
|
||||
try transport.snapshotSQLite(remotePath: remotePath)
|
||||
}
|
||||
inFlight[contextID] = task
|
||||
defer { inFlight[contextID] = nil }
|
||||
return try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
actor HermesDataService {
|
||||
private var db: OpaquePointer?
|
||||
private var hasV07Schema = false
|
||||
/// Local filesystem path we last opened. For remote contexts this is
|
||||
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
||||
private var openedAtPath: String?
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
func open() async -> Bool {
|
||||
if db != nil { return true }
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
// Pull a fresh snapshot from the remote host. Uses `sqlite3
|
||||
// .backup` on the remote, which is WAL-safe; a plain cp would
|
||||
// corrupt. Routed through SnapshotCoordinator so concurrent
|
||||
// view models don't each spawn a parallel SSH backup for the
|
||||
// same server.
|
||||
let url = try? await SnapshotCoordinator.shared.snapshot(
|
||||
remotePath: context.paths.stateDB,
|
||||
contextID: context.id,
|
||||
transport: transport
|
||||
)
|
||||
guard let url else { return false }
|
||||
localPath = url.path
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: localPath) else { return false }
|
||||
}
|
||||
// Remote snapshots are point-in-time copies that no one writes to;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
// locking entirely, which is both faster and avoids spurious
|
||||
// "unable to open database file" errors if the snapshot ever gets
|
||||
// pulled mid-checkpoint. Local points at the live Hermes DB where
|
||||
// the process already has WAL enabled in the header, so a plain
|
||||
// readonly open is the right thing.
|
||||
let flags: Int32
|
||||
let openPath: String
|
||||
if context.isRemote {
|
||||
openPath = "file:\(localPath)?immutable=1"
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI
|
||||
} else {
|
||||
openPath = localPath
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
}
|
||||
let result = sqlite3_open_v2(openPath, &db, flags, nil)
|
||||
guard result == SQLITE_OK else {
|
||||
db = nil
|
||||
return false
|
||||
}
|
||||
openedAtPath = localPath
|
||||
detectSchema()
|
||||
return true
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@discardableResult
|
||||
func refresh() async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
}
|
||||
|
||||
func close() {
|
||||
if let db {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
db = nil
|
||||
}
|
||||
|
||||
// MARK: - Schema Detection
|
||||
|
||||
private func detectSchema() {
|
||||
guard let db else { return }
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
|
||||
hasV07Schema = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Queries
|
||||
|
||||
private var sessionColumns: String {
|
||||
var cols = """
|
||||
id, source, user_id, model, title, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
estimated_cost_usd
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
|
||||
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, parentId, -1, sqliteTransient)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
// MARK: - Message Queries
|
||||
|
||||
private var messageColumns: String {
|
||||
var cols = """
|
||||
id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
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)
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sanitized = sanitizeFTSQuery(query)
|
||||
guard !sanitized.isEmpty else { return [] }
|
||||
let msgCols = hasV07Schema
|
||||
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
|
||||
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
|
||||
let sql = """
|
||||
SELECT \(msgCols)
|
||||
FROM messages_fts fts
|
||||
JOIN messages m ON m.id = fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
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, sanitized, -1, sqliteTransient)
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchToolResult(callId: String) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, callId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT \(messageColumns)
|
||||
FROM messages
|
||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||
FROM messages m
|
||||
INNER JOIN (
|
||||
SELECT session_id, MIN(id) as min_id
|
||||
FROM messages
|
||||
WHERE role = 'user' AND content <> ''
|
||||
GROUP BY session_id
|
||||
) first ON m.id = first.min_id
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var previews: [String: String] = [:]
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let sessionId = columnText(stmt!, 0)
|
||||
let preview = columnText(stmt!, 1)
|
||||
previews[sessionId] = preview
|
||||
}
|
||||
return previews
|
||||
}
|
||||
|
||||
// MARK: - Single-Row Queries
|
||||
|
||||
struct MessageFingerprint: Equatable, Sendable {
|
||||
let count: Int
|
||||
let maxId: Int
|
||||
let maxTimestamp: Double
|
||||
|
||||
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
|
||||
}
|
||||
|
||||
func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
|
||||
guard let db else { return .empty }
|
||||
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return MessageFingerprint(
|
||||
count: Int(sqlite3_column_int(stmt, 0)),
|
||||
maxId: Int(sqlite3_column_int(stmt, 1)),
|
||||
maxTimestamp: sqlite3_column_double(stmt, 2)
|
||||
)
|
||||
}
|
||||
|
||||
func fetchMessageCount(sessionId: String) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int(stmt, 0))
|
||||
}
|
||||
|
||||
func fetchSession(id: String) -> HermesSession? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, id, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return sessionFromRow(stmt!)
|
||||
}
|
||||
|
||||
func fetchMostRecentlyActiveSessionId() -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql: String
|
||||
if after != nil {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL AND started_at > ? ORDER BY started_at DESC LIMIT 1"
|
||||
} else {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT 1"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
if let after {
|
||||
sqlite3_bind_double(stmt, 1, after.timeIntervalSince1970)
|
||||
}
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
// MARK: - Stats
|
||||
|
||||
struct SessionStats: Sendable {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
let totalToolCalls: Int
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
let totalReasoningTokens: Int
|
||||
let totalActualCostUSD: Double
|
||||
|
||||
static let empty = SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||
)
|
||||
}
|
||||
|
||||
func fetchStats() -> SessionStats {
|
||||
guard let db else { return .empty }
|
||||
let sql: String
|
||||
if hasV07Schema {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0),
|
||||
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
} else {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return SessionStats(
|
||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Insights Queries
|
||||
|
||||
func fetchUserMessageCount(since: Date) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = """
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int(stmt, 0))
|
||||
}
|
||||
|
||||
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT m.tool_name, COUNT(*) as cnt
|
||||
FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||
GROUP BY m.tool_name
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var results: [(name: String, count: Int)] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let name = columnText(stmt!, 0)
|
||||
let count = Int(sqlite3_column_int(stmt!, 1))
|
||||
results.append((name: name, count: count))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var hours: [Int: Int] = [:]
|
||||
let calendar = Calendar.current
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let ts = sqlite3_column_double(stmt!, 0)
|
||||
let date = Date(timeIntervalSince1970: ts)
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
hours[hour, default: 0] += 1
|
||||
}
|
||||
return hours
|
||||
}
|
||||
|
||||
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var days: [Int: Int] = [:]
|
||||
let calendar = Calendar.current
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let ts = sqlite3_column_double(stmt!, 0)
|
||||
let date = Date(timeIntervalSince1970: ts)
|
||||
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
|
||||
days[weekday, default: 0] += 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func stateDBModificationDate() -> Date? {
|
||||
// For remote contexts we stat the remote paths. For local it's the
|
||||
// same FileManager lookup as before, just via the transport.
|
||||
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
||||
let dbDate = transport.stat(context.paths.stateDB)?.mtime
|
||||
if let w = walDate, let d = dbDate {
|
||||
return max(w, d)
|
||||
}
|
||||
return walDate ?? dbDate
|
||||
}
|
||||
|
||||
// MARK: - Row Parsing
|
||||
|
||||
private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession {
|
||||
HermesSession(
|
||||
id: columnText(stmt, 0),
|
||||
source: columnText(stmt, 1),
|
||||
userId: columnOptionalText(stmt, 2),
|
||||
model: columnOptionalText(stmt, 3),
|
||||
title: columnOptionalText(stmt, 4),
|
||||
parentSessionId: columnOptionalText(stmt, 5),
|
||||
startedAt: columnDate(stmt, 6),
|
||||
endedAt: columnDate(stmt, 7),
|
||||
endReason: columnOptionalText(stmt, 8),
|
||||
messageCount: Int(sqlite3_column_int(stmt, 9)),
|
||||
toolCallCount: Int(sqlite3_column_int(stmt, 10)),
|
||||
inputTokens: Int(sqlite3_column_int(stmt, 11)),
|
||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
|
||||
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
|
||||
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
|
||||
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage {
|
||||
let toolCallsJSON = columnOptionalText(stmt, 5)
|
||||
let toolCalls = parseToolCalls(toolCallsJSON)
|
||||
return HermesMessage(
|
||||
id: Int(sqlite3_column_int(stmt, 0)),
|
||||
sessionId: columnText(stmt, 1),
|
||||
role: columnText(stmt, 2),
|
||||
content: columnText(stmt, 3),
|
||||
toolCallId: columnOptionalText(stmt, 4),
|
||||
toolCalls: toolCalls,
|
||||
toolName: columnOptionalText(stmt, 6),
|
||||
timestamp: columnDate(stmt, 7),
|
||||
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
||||
finishReason: columnOptionalText(stmt, 9),
|
||||
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||
guard let json, !json.isEmpty,
|
||||
let data = json.data(using: .utf8) else { return [] }
|
||||
do {
|
||||
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||
if let cStr = sqlite3_column_text(stmt, col) {
|
||||
return String(cString: cStr)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func columnOptionalText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL,
|
||||
let cStr = sqlite3_column_text(stmt, col) else { return nil }
|
||||
return String(cString: cStr)
|
||||
}
|
||||
|
||||
private func columnDate(_ stmt: OpaquePointer, _ col: Int32) -> Date? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL else { return nil }
|
||||
let value = sqlite3_column_double(stmt, col)
|
||||
return Date(timeIntervalSince1970: value)
|
||||
}
|
||||
|
||||
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||
raw.split(separator: " ")
|
||||
.map { token in
|
||||
let t = String(token)
|
||||
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||
}
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
|
||||
/// ordering of keys we don't touch.
|
||||
///
|
||||
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
|
||||
/// and optional double-quoted values for strings with spaces or special chars.
|
||||
/// We do NOT attempt to implement full shell-style escaping; the fields we write
|
||||
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags — none of which
|
||||
/// contain characters needing escaping beyond double-quoting.
|
||||
///
|
||||
/// Design choices:
|
||||
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
|
||||
/// than deleting it, so users can restore a key by uncommenting without losing
|
||||
/// their value.
|
||||
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
|
||||
/// written file if Scarf crashes mid-write.
|
||||
/// - **Never logs values**: secrets flow through this service.
|
||||
struct HermesEnvService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
|
||||
|
||||
/// Path to `~/.hermes/.env`. Kept configurable for tests.
|
||||
let path: String
|
||||
let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.path = context.paths.envFile
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Escape hatch for tests that want to point at a fixture path directly.
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
|
||||
/// assignments are ignored. Missing file returns an empty dict.
|
||||
func load() -> [String: String] {
|
||||
guard let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return [:]
|
||||
}
|
||||
var result: [String: String] = [:]
|
||||
for line in content.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
// Skip blanks and comments. A line beginning with `#` is either a pure
|
||||
// comment or a disabled assignment — both should be treated as "unset".
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
guard let eq = trimmed.firstIndex(of: "=") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
||||
result[key] = Self.stripEnvQuotes(raw)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func get(_ key: String) -> String? {
|
||||
load()[key]
|
||||
}
|
||||
|
||||
/// Write/update a single key. Preserves the position of existing assignments
|
||||
/// (even if they were commented out — the new assignment replaces the comment
|
||||
/// line in place). New keys are appended at the end.
|
||||
@discardableResult
|
||||
func set(_ key: String, value: String) -> Bool {
|
||||
setMany([key: value])
|
||||
}
|
||||
|
||||
/// Update multiple keys in one atomic rewrite. Use this when a form saves
|
||||
/// several fields at once so the file doesn't get repeatedly rewritten.
|
||||
///
|
||||
/// Returns `true` on success, `false` if the atomic rewrite failed.
|
||||
@discardableResult
|
||||
func setMany(_ pairs: [String: String]) -> Bool {
|
||||
var remaining = pairs
|
||||
var lines: [String]
|
||||
|
||||
// Start from existing file contents, or a minimal header if creating new.
|
||||
if let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) {
|
||||
lines = content.components(separatedBy: "\n")
|
||||
// Trim a single trailing empty line from splitting the final newline;
|
||||
// we'll re-add it on write.
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
} else {
|
||||
lines = ["# Hermes Agent Environment Configuration"]
|
||||
}
|
||||
|
||||
// First pass: update in-place (handles both live and commented-out lines).
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line) else { continue }
|
||||
if let newValue = remaining.removeValue(forKey: match.key) {
|
||||
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
|
||||
lines[idx] = Self.formatLine(key: match.key, value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: append any keys that didn't match an existing line.
|
||||
if !remaining.isEmpty {
|
||||
// Leave a blank line before appending new keys for visual separation.
|
||||
if let last = lines.last, !last.isEmpty {
|
||||
lines.append("")
|
||||
}
|
||||
for key in remaining.keys.sorted() {
|
||||
lines.append(Self.formatLine(key: key, value: remaining[key]!))
|
||||
}
|
||||
}
|
||||
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
/// Comment out a key. The value is preserved so the user can restore by
|
||||
/// uncommenting. If the key doesn't exist, this is a no-op.
|
||||
@discardableResult
|
||||
func unset(_ key: String) -> Bool {
|
||||
guard let data = try? transport.readFile(path),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return true
|
||||
}
|
||||
var lines = content.components(separatedBy: "\n")
|
||||
if lines.last == "" { lines.removeLast() }
|
||||
|
||||
var changed = false
|
||||
for (idx, line) in lines.enumerated() {
|
||||
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
|
||||
// Skip lines that are already commented — nothing to do.
|
||||
if Self.isCommentedOutAssignment(line) { continue }
|
||||
lines[idx] = "# " + line
|
||||
changed = true
|
||||
}
|
||||
guard changed else { return true }
|
||||
return atomicWrite(lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Writes the entire file in one shot through the transport. For local
|
||||
/// contexts this ends up doing the same atomic-rename dance as before
|
||||
/// (via `LocalTransport.writeFile`). For remote contexts this goes
|
||||
/// through `scp` + remote `mv`, still atomic from Hermes's point of
|
||||
/// view.
|
||||
private func atomicWrite(_ content: String) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else { return false }
|
||||
do {
|
||||
try transport.writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a key name and whether the line was active or commented-out.
|
||||
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
|
||||
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
|
||||
var work = line.trimmingCharacters(in: .whitespaces)
|
||||
var active = true
|
||||
if work.hasPrefix("#") {
|
||||
active = false
|
||||
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
guard let eq = work.firstIndex(of: "=") else { return nil }
|
||||
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
// Reject non-identifier looking keys to avoid matching prose in comments
|
||||
// (e.g. "# This is a note about something = nice").
|
||||
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
|
||||
return nil
|
||||
}
|
||||
return (key, active)
|
||||
}
|
||||
|
||||
private static func isCommentedOutAssignment(_ line: String) -> Bool {
|
||||
guard let match = extractKey(fromLine: line) else { return false }
|
||||
return !match.active
|
||||
}
|
||||
|
||||
/// Format a single `KEY=value` line. Values containing whitespace or shell
|
||||
/// metacharacters get double-quoted; simple tokens go in unquoted to match
|
||||
/// hermes's own output style.
|
||||
private static func formatLine(key: String, value: String) -> String {
|
||||
if Self.needsQuoting(value) {
|
||||
// Escape embedded backslashes and double quotes, then wrap.
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\(key)=\"\(escaped)\""
|
||||
}
|
||||
return "\(key)=\(value)"
|
||||
}
|
||||
|
||||
private static func needsQuoting(_ value: String) -> Bool {
|
||||
if value.isEmpty { return false }
|
||||
// Whitespace, shell metacharacters, or quotes trigger quoting.
|
||||
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
|
||||
return value.contains(where: { metacharacters.contains($0) })
|
||||
}
|
||||
|
||||
/// Strip one layer of matched double or single quotes from a loaded value.
|
||||
private static func stripEnvQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
|
||||
var inner = String(s.dropFirst().dropLast())
|
||||
if first == "\"" {
|
||||
inner = inner
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
}
|
||||
return inner
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class HermesFileWatcher {
|
||||
private(set) var lastChangeDate = Date()
|
||||
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||
private var timer: Timer?
|
||||
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
|
||||
/// on `stopWatching()`.
|
||||
private var remotePollTask: Task<Void, Never>?
|
||||
|
||||
let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Canonical list of paths we observe. Used for both FSEvents (local)
|
||||
/// and mtime polling (remote).
|
||||
private var watchedCorePaths: [String] {
|
||||
let paths = context.paths
|
||||
return [
|
||||
paths.stateDB,
|
||||
paths.stateDB + "-wal",
|
||||
paths.configYAML,
|
||||
paths.home + "/.env",
|
||||
paths.memoryMD,
|
||||
paths.userMD,
|
||||
paths.cronJobsJSON,
|
||||
paths.gatewayStateJSON,
|
||||
paths.agentLog,
|
||||
paths.errorsLog,
|
||||
paths.gatewayLog,
|
||||
paths.projectsRegistry,
|
||||
paths.mcpTokensDir
|
||||
]
|
||||
}
|
||||
|
||||
func startWatching() {
|
||||
if context.isRemote {
|
||||
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
|
||||
// the transport's AsyncStream, which polls stat mtime on a
|
||||
// shared ControlMaster channel (~5ms per tick).
|
||||
let stream = transport.watchPaths(watchedCorePaths)
|
||||
remotePollTask = Task { [weak self] in
|
||||
for await _ in stream {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for path in watchedCorePaths {
|
||||
if let source = makeSource(for: path) {
|
||||
coreSources.append(source)
|
||||
}
|
||||
}
|
||||
// No heartbeat timer: every observing view runs its `.onChange`
|
||||
// refresh whenever `lastChangeDate` ticks, so a 5s unconditional
|
||||
// tick was triggering wasted reloads across many subscribers
|
||||
// (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat).
|
||||
// FSEvents reliably fires on real changes; menu-bar Start/Stop
|
||||
// touches `gateway_state.json` which the watcher catches.
|
||||
}
|
||||
|
||||
func stopWatching() {
|
||||
for source in coreSources + projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
coreSources.removeAll()
|
||||
projectSources.removeAll()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
remotePollTask?.cancel()
|
||||
remotePollTask = nil
|
||||
}
|
||||
|
||||
func updateProjectWatches(_ dashboardPaths: [String]) {
|
||||
// Remote contexts don't support per-project FSEvents watches today —
|
||||
// the shared mtime poll covers the core set. Adding per-project
|
||||
// polling is a Phase 4 polish item.
|
||||
guard !context.isRemote else { return }
|
||||
for source in projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
projectSources.removeAll()
|
||||
for path in dashboardPaths {
|
||||
if let source = makeSource(for: path) {
|
||||
projectSources.append(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
||||
let fd = Darwin.open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return nil }
|
||||
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename],
|
||||
queue: .main
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.lastChangeDate = Date()
|
||||
}
|
||||
source.setCancelHandler {
|
||||
Darwin.close(fd)
|
||||
}
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopWatching()
|
||||
}
|
||||
}
|
||||