mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,5 +1,6 @@
|
||||
# Xcode
|
||||
build/
|
||||
.gh-pages-worktree/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
|
||||
@@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -10,44 +10,87 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
|
||||
<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 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** — New section for Personalities, Quick Commands, Plugins, Webhooks, Profiles
|
||||
|
||||
See the full [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0).
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** — System health, token usage, recent sessions with live refresh
|
||||
- **Insights** — Usage analytics with token breakdown, 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, tool call inspection, full-text search, rename, delete, and JSONL export
|
||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
|
||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
|
||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh
|
||||
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status
|
||||
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, their status, prompts, and output
|
||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||
- **Settings** — Structured config editor for all Hermes settings
|
||||
- **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 26.2+
|
||||
- Xcode 26.3+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/`
|
||||
- macOS 14.6+ (Sonoma)
|
||||
- Xcode 16.0+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
|
||||
|
||||
### Compatibility
|
||||
|
||||
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
|
||||
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.6.0 (2026-03-31, latest) | Verified |
|
||||
| v0.7.0 (2026-04-03) | Verified |
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13) | Verified (recommended for full 1.6 feature support) |
|
||||
|
||||
Scarf 1.6 targets Hermes v0.9.0 specifically for the new Platforms, Credentials, Skills Hub, and Cron write features. Earlier Hermes versions remain supported for the monitoring and session features but may not expose every new setup form.
|
||||
|
||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||
|
||||
@@ -55,11 +98,14 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
||||
|
||||
### Pre-built Binary (no Xcode required)
|
||||
|
||||
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
|
||||
1. Download `Scarf-vX.X.X-Universal.zip`
|
||||
2. Unzip and drag **Scarf.app** to Applications
|
||||
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||
|
||||
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
|
||||
|
||||
@@ -90,10 +136,11 @@ scarf/
|
||||
Sessions/ Conversation browser with rename, delete, export
|
||||
Activity/ Tool execution feed with inspector
|
||||
Projects/ Agent-generated project dashboards with widget rendering
|
||||
Chat/ Embedded terminal via SwiftTerm with voice controls
|
||||
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
|
||||
@@ -114,11 +161,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
| `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 |
|
||||
|
||||
@@ -129,6 +179,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
||||
| 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.
|
||||
|
||||
@@ -136,7 +187,7 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
|
||||
|
||||
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 spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
|
||||
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.
|
||||
|
||||
@@ -144,7 +195,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an
|
||||
|
||||
## 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, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
||||
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
|
||||
|
||||
@@ -153,6 +204,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr
|
||||
- **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
|
||||
@@ -227,6 +279,23 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
|
||||
| `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
|
||||
|
||||
@@ -236,7 +305,7 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
|
||||
|
||||
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, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
> 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.
|
||||
|
||||
@@ -260,6 +329,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root:
|
||||
|
||||
- `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, and list widgets. Register the project
|
||||
> 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.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* 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 */
|
||||
@@ -33,9 +34,22 @@
|
||||
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>";
|
||||
};
|
||||
@@ -57,6 +71,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -118,6 +133,7 @@
|
||||
name = scarf;
|
||||
packageProductDependencies = (
|
||||
53SWIFTTERM0001 /* SwiftTerm */,
|
||||
53SPARKLE00011 /* Sparkle */,
|
||||
);
|
||||
productName = scarf;
|
||||
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
|
||||
@@ -203,6 +219,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
|
||||
@@ -407,22 +424,20 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -443,22 +458,20 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = scarf/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -475,11 +488,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -496,11 +509,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -516,10 +529,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -535,10 +548,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -592,6 +605,14 @@
|
||||
/* 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";
|
||||
@@ -603,6 +624,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
53SPARKLE00011 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
53SWIFTTERM0001 /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
|
||||
@@ -14,34 +14,28 @@ struct ContentView: View {
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .insights:
|
||||
InsightsView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .health:
|
||||
HealthView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .dashboard: DashboardView()
|
||||
case .insights: InsightsView()
|
||||
case .sessions: SessionsView()
|
||||
case .activity: ActivityView()
|
||||
case .projects: ProjectsView()
|
||||
case .chat: ChatView()
|
||||
case .memory: MemoryView()
|
||||
case .skills: SkillsView()
|
||||
case .platforms: PlatformsView()
|
||||
case .personalities: PersonalitiesView()
|
||||
case .quickCommands: QuickCommandsView()
|
||||
case .credentialPools: CredentialPoolsView()
|
||||
case .plugins: PluginsView()
|
||||
case .webhooks: WebhooksView()
|
||||
case .profiles: ProfilesView()
|
||||
case .tools: ToolsView()
|
||||
case .mcpServers: MCPServersView()
|
||||
case .gateway: GatewayView()
|
||||
case .cron: CronView()
|
||||
case .health: HealthView()
|
||||
case .logs: LogsView()
|
||||
case .settings: SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
struct ACPRequest: Encodable {
|
||||
let jsonrpc = "2.0"
|
||||
let id: Int
|
||||
let method: String
|
||||
let params: [String: AnyCodable]
|
||||
}
|
||||
|
||||
struct ACPRawMessage: Decodable {
|
||||
let jsonrpc: String?
|
||||
let id: Int?
|
||||
let method: String?
|
||||
let result: AnyCodable?
|
||||
let error: ACPError?
|
||||
let params: AnyCodable?
|
||||
|
||||
var isResponse: Bool { id != nil && method == nil }
|
||||
var isNotification: Bool { method != nil && id == nil }
|
||||
var isRequest: Bool { method != nil && id != nil }
|
||||
}
|
||||
|
||||
struct ACPError: Decodable, Sendable {
|
||||
let code: Int
|
||||
let message: String
|
||||
}
|
||||
|
||||
// MARK: - AnyCodable (for dynamic JSON)
|
||||
|
||||
struct AnyCodable: Codable, Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: 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: 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
|
||||
|
||||
var stringValue: String? { value as? String }
|
||||
var intValue: Int? { value as? Int }
|
||||
var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,304 @@
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||
}
|
||||
|
||||
struct CheckpointSettings: Sendable, Equatable {
|
||||
var enabled: Bool
|
||||
var maxSnapshots: Int
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||
}
|
||||
|
||||
/// WhatsApp settings under `whatsapp.*`.
|
||||
struct WhatsAppSettings: Sendable, Equatable {
|
||||
var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||
var replyPrefix: String
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -15,6 +313,51 @@ struct HermesConfig: Sendable {
|
||||
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
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -30,7 +373,48 @@ struct HermesConfig: Sendable {
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
enum HermesPaths: Sendable {
|
||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
||||
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||
?? NSHomeDirectory()
|
||||
|
||||
nonisolated static let home: String = userHome + "/.hermes"
|
||||
nonisolated static let stateDB: String = home + "/state.db"
|
||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||
nonisolated static let memoriesDir: String = home + "/memories"
|
||||
@@ -14,8 +17,55 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
||||
nonisolated static let skillsDir: String = home + "/skills"
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
|
||||
nonisolated static let scarfDir: String = home + "/scarf"
|
||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||
|
||||
/// Install locations we look for the `hermes` binary in, in priority order.
|
||||
/// Checked every access so a user installing via a different method doesn't
|
||||
/// need to relaunch Scarf.
|
||||
nonisolated static let hermesBinaryCandidates: [String] = [
|
||||
userHome + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||
userHome + "/.hermes/bin/hermes" // Some self-install layouts
|
||||
]
|
||||
|
||||
/// Resolved path to the `hermes` executable. Returns the first candidate
|
||||
/// that exists and is executable; falls back to the pipx default so error
|
||||
/// messages ("Expected at …") still make sense on a fresh machine.
|
||||
nonisolated static var hermesBinary: String {
|
||||
for path in hermesBinaryCandidates
|
||||
where FileManager.default.isExecutableFile(atPath: path) {
|
||||
return path
|
||||
}
|
||||
return hermesBinaryCandidates[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
let nextRunAt: String?
|
||||
let lastRunAt: String?
|
||||
let lastError: String?
|
||||
let preRunScript: String?
|
||||
let deliveryFailures: Int?
|
||||
let lastDeliveryError: String?
|
||||
let timeoutType: String?
|
||||
let timeoutSeconds: Int?
|
||||
let silent: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver
|
||||
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"
|
||||
}
|
||||
|
||||
var stateIcon: String {
|
||||
@@ -30,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
default: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
|
||||
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 {
|
||||
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
switch functionName {
|
||||
case "read_file", "search_files", "vision_analyze": return .read
|
||||
case "write_file", "patch": return .edit
|
||||
case "terminal": return .execute
|
||||
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
|
||||
|
||||
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let estimatedCostUSD: Double?
|
||||
let reasoningTokens: Int
|
||||
let actualCostUSD: Double?
|
||||
let costStatus: String?
|
||||
let billingProvider: String?
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens }
|
||||
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 }
|
||||
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
var sourceIcon: String {
|
||||
switch source {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
|
||||
let category: String
|
||||
let path: String
|
||||
let files: [String]
|
||||
let requiredConfig: [String]
|
||||
}
|
||||
|
||||
@@ -16,13 +16,39 @@ struct HermesToolPlatform: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
enum KnownPlatforms {
|
||||
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||
static let all: [HermesToolPlatform] = [
|
||||
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable {
|
||||
|
||||
// List
|
||||
let items: [ListItem]?
|
||||
|
||||
// Webview
|
||||
let url: String?
|
||||
let height: Double?
|
||||
}
|
||||
|
||||
// MARK: - Widget Value (String or Number)
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
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 = ""
|
||||
|
||||
/// 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
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = ["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.
|
||||
// Use the enriched environment 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) {
|
||||
await self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||
}
|
||||
|
||||
do {
|
||||
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
|
||||
await self?.handleMessage(message)
|
||||
} catch {
|
||||
await 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 {
|
||||
await 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
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import SQLite3
|
||||
|
||||
actor HermesDataService {
|
||||
private var db: OpaquePointer?
|
||||
private var hasV07Schema = false
|
||||
|
||||
func open() -> Bool {
|
||||
if db != nil { return true }
|
||||
let path = HermesPaths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
@@ -14,6 +16,7 @@ actor HermesDataService {
|
||||
return false
|
||||
}
|
||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||
detectSchema()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,17 +27,39 @@ actor HermesDataService {
|
||||
db = nil
|
||||
}
|
||||
|
||||
func fetchSessions(limit: Int = 100) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT 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
|
||||
FROM sessions
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
// 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) }
|
||||
@@ -47,19 +72,56 @@ actor HermesDataService {
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
"""
|
||||
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_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
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 {
|
||||
@@ -68,11 +130,15 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
||||
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 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
|
||||
SELECT \(msgCols)
|
||||
FROM messages_fts fts
|
||||
JOIN messages m ON m.id = fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
@@ -82,7 +148,7 @@ actor HermesDataService {
|
||||
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, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
@@ -92,11 +158,21 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
|
||||
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 id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
SELECT \(messageColumns)
|
||||
FROM messages
|
||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||
ORDER BY timestamp DESC
|
||||
@@ -114,10 +190,10 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
|
||||
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT m.session_id, substr(m.content, 1, 100)
|
||||
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||
FROM messages m
|
||||
INNER JOIN (
|
||||
SELECT session_id, MIN(id) as min_id
|
||||
@@ -142,6 +218,83 @@ actor HermesDataService {
|
||||
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
|
||||
@@ -149,71 +302,59 @@ actor HermesDataService {
|
||||
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 SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
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
|
||||
"""
|
||||
}
|
||||
let 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 SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
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 SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
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)
|
||||
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 fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT 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
|
||||
FROM sessions
|
||||
WHERE 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 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.started_at >= ?
|
||||
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 }
|
||||
@@ -229,7 +370,7 @@ actor HermesDataService {
|
||||
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.started_at >= ?
|
||||
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
|
||||
"""
|
||||
@@ -250,7 +391,7 @@ actor HermesDataService {
|
||||
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE started_at >= ?
|
||||
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 [:] }
|
||||
@@ -271,7 +412,7 @@ actor HermesDataService {
|
||||
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE started_at >= ?
|
||||
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 [:] }
|
||||
@@ -320,7 +461,11 @@ actor HermesDataService {
|
||||
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
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -337,14 +482,20 @@ actor HermesDataService {
|
||||
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)
|
||||
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 [] }
|
||||
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
|
||||
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 {
|
||||
@@ -365,4 +516,17 @@ actor HermesDataService {
|
||||
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,215 @@
|
||||
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
|
||||
|
||||
init(path: String = HermesPaths.home + "/.env") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// 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 content = try? String(contentsOfFile: path, 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 content = try? String(contentsOfFile: path, 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 content = try? String(contentsOfFile: path, 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 via a tmp + rename to avoid corrupting
|
||||
/// `.env` if the process is killed mid-write. Preserves `0600` permissions
|
||||
/// since `.env` typically holds secrets.
|
||||
private func atomicWrite(_ content: String) -> Bool {
|
||||
let tmp = path + ".tmp"
|
||||
do {
|
||||
try content.write(toFile: tmp, atomically: false, encoding: .utf8)
|
||||
// Mirror the typical `.env` mode of `0600` (owner read/write only).
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
|
||||
// Swap into place. FileManager.replaceItem handles the replacement
|
||||
// atomically on the same volume; fall back to a two-step rename.
|
||||
let destURL = URL(fileURLWithPath: path)
|
||||
let tmpURL = URL(fileURLWithPath: tmp)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: tmpURL, to: destURL)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Failed to write .env: \(error.localizedDescription)")
|
||||
try? FileManager.default.removeItem(atPath: tmp)
|
||||
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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,16 @@ final class HermesFileWatcher {
|
||||
HermesPaths.stateDB,
|
||||
HermesPaths.stateDB + "-wal",
|
||||
HermesPaths.configYAML,
|
||||
HermesPaths.home + "/.env", // Platform setup forms write here.
|
||||
HermesPaths.memoryMD,
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
HermesPaths.projectsRegistry,
|
||||
HermesPaths.mcpTokensDir
|
||||
]
|
||||
|
||||
for path in paths {
|
||||
|
||||
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let timestamp: String
|
||||
let level: LogLevel
|
||||
let sessionId: String?
|
||||
let logger: String
|
||||
let message: String
|
||||
let raw: String
|
||||
@@ -39,12 +40,16 @@ actor HermesLogService {
|
||||
}
|
||||
|
||||
func closeLog() {
|
||||
try? fileHandle?.close()
|
||||
do {
|
||||
try fileHandle?.close()
|
||||
} catch {
|
||||
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||
}
|
||||
fileHandle = nil
|
||||
currentPath = nil
|
||||
}
|
||||
|
||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
||||
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||
guard let path = currentPath,
|
||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
@@ -68,23 +73,30 @@ actor HermesLogService {
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
entryCounter += 1
|
||||
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
|
||||
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
|
||||
// Session tag is optional — earlier Hermes releases and out-of-session lines omit it.
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
||||
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
||||
let logger = String(line[Range(match.range(at: 3), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 4), in: line)!])
|
||||
let sessionId: String? = {
|
||||
let range = match.range(at: 3)
|
||||
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
|
||||
return String(line[r])
|
||||
}()
|
||||
let logger = String(line[Range(match.range(at: 4), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 5), in: line)!])
|
||||
return LogEntry(
|
||||
id: entryCounter,
|
||||
timestamp: timestamp,
|
||||
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
||||
sessionId: sessionId,
|
||||
logger: logger,
|
||||
message: message,
|
||||
raw: line
|
||||
)
|
||||
}
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID + ":" + modelID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let modelID: String
|
||||
let modelName: String
|
||||
let contextWindow: Int?
|
||||
let maxOutput: Int?
|
||||
let costInput: Double? // USD per 1M input tokens
|
||||
let costOutput: Double? // USD per 1M output tokens
|
||||
let reasoning: Bool
|
||||
let toolCall: Bool
|
||||
let releaseDate: String?
|
||||
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
return String(format: "$%.2f / $%.2f", input, output)
|
||||
}
|
||||
|
||||
/// Display-friendly context window ("200K", "1M", etc.).
|
||||
var contextDisplay: String? {
|
||||
guard let ctx = contextWindow else { return nil }
|
||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||
return "\(ctx)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider summary — one row in the left column of the picker.
|
||||
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
var id: String { providerID }
|
||||
|
||||
let providerID: String
|
||||
let providerName: String
|
||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
let docURL: String?
|
||||
let modelCount: Int
|
||||
}
|
||||
|
||||
/// Reads the models.dev catalog that hermes caches at
|
||||
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
|
||||
/// call (~1500 models across ~110 providers).
|
||||
///
|
||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
||||
/// field we care about is optional on disk — providers may omit cost, context
|
||||
/// limits, etc.
|
||||
struct ModelCatalogService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||
let path: String
|
||||
|
||||
init(path: String = HermesPaths.home + "/models_dev_cache.json") {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
let providerName = provider.name ?? providerID
|
||||
let models = (provider.models ?? [:]).map { (id, m) in
|
||||
HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: providerName,
|
||||
modelID: id,
|
||||
modelName: m.name ?? id,
|
||||
contextWindow: m.limit?.context,
|
||||
maxOutput: m.limit?.output,
|
||||
costInput: m.cost?.input,
|
||||
costOutput: m.cost?.output,
|
||||
reasoning: m.reasoning ?? false,
|
||||
toolCall: m.tool_call ?? false,
|
||||
releaseDate: m.release_date
|
||||
)
|
||||
}
|
||||
return models.sorted { lhs, rhs in
|
||||
// Newest-first by release date if both are known; otherwise fall
|
||||
// back to alphabetical on display name.
|
||||
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
|
||||
return lDate > rDate
|
||||
}
|
||||
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
for (providerID, p) in catalog {
|
||||
if p.models?[modelID] != nil {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Handle provider-prefixed IDs like "openai/gpt-4o" — look up the
|
||||
// prefix before the slash.
|
||||
if let slash = modelID.firstIndex(of: "/") {
|
||||
let prefix = String(modelID[modelID.startIndex..<slash])
|
||||
if let p = catalog[prefix] {
|
||||
return HermesProviderInfo(
|
||||
providerID: prefix,
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
guard let catalog = loadCatalog(),
|
||||
let provider = catalog[providerID],
|
||||
let raw = provider.models?[modelID] else { return nil }
|
||||
return HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: provider.name ?? providerID,
|
||||
modelID: modelID,
|
||||
modelName: raw.name ?? modelID,
|
||||
contextWindow: raw.limit?.context,
|
||||
maxOutput: raw.limit?.output,
|
||||
costInput: raw.cost?.input,
|
||||
costOutput: raw.cost?.output,
|
||||
reasoning: raw.reasoning ?? false,
|
||||
toolCall: raw.tool_call ?? false,
|
||||
releaseDate: raw.release_date
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||
} catch {
|
||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed representations — we decode a subset of fields and tolerate
|
||||
// anything new hermes adds later. `snake_case` field names match the file.
|
||||
private struct ProviderEntry: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let env: [String]?
|
||||
let doc: String?
|
||||
let models: [String: ModelEntry]?
|
||||
}
|
||||
|
||||
private struct ModelEntry: Decodable {
|
||||
let name: String?
|
||||
let reasoning: Bool?
|
||||
let tool_call: Bool?
|
||||
let release_date: String?
|
||||
let cost: CostEntry?
|
||||
let limit: LimitEntry?
|
||||
}
|
||||
|
||||
private struct CostEntry: Decodable {
|
||||
let input: Double?
|
||||
let output: Double?
|
||||
}
|
||||
|
||||
private struct LimitEntry: Decodable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,23 @@ struct ProjectDashboardService: Sendable {
|
||||
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
return (try? JSONDecoder().decode(ProjectRegistry.self, from: data))
|
||||
?? ProjectRegistry(projects: [])
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
}
|
||||
|
||||
func saveRegistry(_ registry: ProjectRegistry) {
|
||||
let dir = HermesPaths.scarfDir
|
||||
if !FileManager.default.fileExists(atPath: dir) {
|
||||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||
// Pretty-print for readability (agents may read this file)
|
||||
@@ -33,7 +42,12 @@ struct ProjectDashboardService: Sendable {
|
||||
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
|
||||
///
|
||||
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
|
||||
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
|
||||
/// checks, and observable state for the Settings screen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class UpdaterService: NSObject {
|
||||
private let controller: SPUStandardUpdaterController
|
||||
|
||||
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { controller.updater.automaticallyChecksForUpdates }
|
||||
set { controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
/// Last time Sparkle checked the appcast (nil before the first check).
|
||||
var lastUpdateCheckDate: Date? {
|
||||
controller.updater.lastUpdateCheckDate
|
||||
}
|
||||
|
||||
override init() {
|
||||
// startingUpdater: true → Sparkle scans for updates on launch per Info.plist schedule.
|
||||
// Default delegates are sufficient for a non-sandboxed app.
|
||||
self.controller = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
|
||||
func checkForUpdates() {
|
||||
controller.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func blockView(_ block: MarkdownBlock) -> some View {
|
||||
switch block {
|
||||
case .heading(let level, let text):
|
||||
headingView(level: level, text: text)
|
||||
case .paragraph(let text):
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
case .codeBlock(let code, let language):
|
||||
codeBlockView(code: code, language: language)
|
||||
case .bulletItem(let text, let indent):
|
||||
bulletView(text: text, indent: indent)
|
||||
case .numberedItem(let number, let text):
|
||||
numberedView(number: number, text: text)
|
||||
case .blockquote(let text):
|
||||
blockquoteView(text: text)
|
||||
case .horizontalRule:
|
||||
Divider().padding(.vertical, 4)
|
||||
case .blank:
|
||||
Spacer().frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block Views
|
||||
|
||||
private func headingView(level: Int, text: String) -> some View {
|
||||
let font: Font = switch level {
|
||||
case 1: .title.bold()
|
||||
case 2: .title2.bold()
|
||||
case 3: .title3.bold()
|
||||
case 4: .headline
|
||||
default: .subheadline.bold()
|
||||
}
|
||||
return Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.font(font)
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, level <= 2 ? 8 : 4)
|
||||
}
|
||||
|
||||
private func codeBlockView(code: String, language: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lang = language, !lang.isEmpty {
|
||||
Text(lang)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(code)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.textBackgroundColor).opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func bulletView(text: String, indent: Int) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\u{2022}")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.leading, CGFloat(indent) * 16)
|
||||
}
|
||||
|
||||
private func numberedView(number: Int, text: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(number).")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func blockquoteView(text: String) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(.blue.opacity(0.5))
|
||||
.frame(width: 3)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
private func parseBlocks() -> [MarkdownBlock] {
|
||||
var blocks: [MarkdownBlock] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
var i = 0
|
||||
|
||||
// Skip YAML frontmatter (--- delimited block at start of file)
|
||||
if i < lines.count && lines[i].trimmingCharacters(in: .whitespaces) == "---" {
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
if lines[i].trimmingCharacters(in: .whitespaces) == "---" {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Blank line
|
||||
if trimmed.isEmpty {
|
||||
if blocks.last != .blank {
|
||||
blocks.append(.blank)
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Code block (fenced)
|
||||
if trimmed.hasPrefix("```") {
|
||||
let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
var codeLines: [String] = []
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
if lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
codeLines.append(lines[i])
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.codeBlock(codeLines.joined(separator: "\n"), language: language.isEmpty ? nil : language))
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading
|
||||
if let heading = parseHeading(trimmed) {
|
||||
blocks.append(heading)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if isHorizontalRule(trimmed) {
|
||||
blocks.append(.horizontalRule)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if trimmed.hasPrefix("> ") {
|
||||
var quoteLines: [String] = []
|
||||
while i < lines.count {
|
||||
let l = lines[i].trimmingCharacters(in: .whitespaces)
|
||||
if l.hasPrefix("> ") {
|
||||
quoteLines.append(String(l.dropFirst(2)))
|
||||
} else if l.hasPrefix(">") {
|
||||
quoteLines.append(String(l.dropFirst(1)))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.blockquote(quoteLines.joined(separator: " ")))
|
||||
continue
|
||||
}
|
||||
|
||||
// Bullet list
|
||||
if let bullet = parseBullet(line) {
|
||||
blocks.append(bullet)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Numbered list
|
||||
if let numbered = parseNumbered(trimmed) {
|
||||
blocks.append(numbered)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph — each line is its own paragraph to preserve line breaks
|
||||
blocks.append(.paragraph(trimmed))
|
||||
i += 1
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private func parseHeading(_ line: String) -> MarkdownBlock? {
|
||||
let levels: [(prefix: String, level: Int)] = [
|
||||
("##### ", 5), ("#### ", 4), ("### ", 3), ("## ", 2), ("# ", 1)
|
||||
]
|
||||
for (prefix, level) in levels {
|
||||
if line.hasPrefix(prefix) {
|
||||
return .heading(level, String(line.dropFirst(prefix.count)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseBullet(_ line: String) -> MarkdownBlock? {
|
||||
let indent = line.prefix(while: { $0 == " " }).count / 2
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("- ") {
|
||||
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
|
||||
}
|
||||
if trimmed.hasPrefix("* ") {
|
||||
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseNumbered(_ line: String) -> MarkdownBlock? {
|
||||
guard let dotIdx = line.firstIndex(of: ".") else { return nil }
|
||||
let numStr = String(line[line.startIndex..<dotIdx])
|
||||
guard let num = Int(numStr), line[line.index(after: dotIdx)...].hasPrefix(" ") else { return nil }
|
||||
let text = String(line[line.index(dotIdx, offsetBy: 2)...])
|
||||
return .numberedItem(num, text)
|
||||
}
|
||||
|
||||
private func isHorizontalRule(_ line: String) -> Bool {
|
||||
let stripped = line.replacingOccurrences(of: " ", with: "")
|
||||
return (stripped.allSatisfy({ $0 == "-" }) && stripped.count >= 3) ||
|
||||
(stripped.allSatisfy({ $0 == "*" }) && stripped.count >= 3) ||
|
||||
(stripped.allSatisfy({ $0 == "_" }) && stripped.count >= 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block Model
|
||||
|
||||
private enum MarkdownBlock: Equatable {
|
||||
case heading(Int, String)
|
||||
case paragraph(String)
|
||||
case codeBlock(String, language: String?)
|
||||
case bulletItem(String, indent: Int)
|
||||
case numberedItem(Int, String)
|
||||
case blockquote(String)
|
||||
case horizontalRule
|
||||
case blank
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum MarkdownRenderer {
|
||||
/// Inline-only rendering — bold, italic, code spans, links. Preserves whitespace/newlines.
|
||||
static func inlineAttributedString(_ text: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: text, options: .init(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||
))) ?? AttributedString(text)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ final class ActivityViewModel {
|
||||
var filterKind: ToolKind?
|
||||
var filterSessionId: String?
|
||||
var selectedEntry: ActivityEntry?
|
||||
var toolResult: String?
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var isLoading = true
|
||||
|
||||
@@ -54,6 +55,15 @@ final class ActivityViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectEntry(_ entry: ActivityEntry?) async {
|
||||
selectedEntry = entry
|
||||
if let entry {
|
||||
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
||||
} else {
|
||||
toolResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
@@ -57,11 +57,8 @@ struct ActivityView: View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedEntry?.id },
|
||||
set: { id in
|
||||
if let id {
|
||||
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id })
|
||||
} else {
|
||||
viewModel.selectedEntry = nil
|
||||
}
|
||||
let entry = id.flatMap { id in viewModel.filteredActivity.first(where: { $0.id == id }) }
|
||||
Task { await viewModel.selectEntry(entry) }
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.filteredActivity) { entry in
|
||||
@@ -146,14 +143,32 @@ struct ActivityView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
if let result = viewModel.toolResult, !result.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(result)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(50)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.textBackgroundColor).opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !entry.messageContent.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Assistant Message")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.messageContent)
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
MarkdownContentView(content: entry.messageContent)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
@@ -14,33 +16,458 @@ final class ChatViewModel {
|
||||
var voiceEnabled = false
|
||||
var ttsEnabled = false
|
||||
var isRecording = false
|
||||
var displayMode: ChatDisplayMode = .richChat
|
||||
let richChatViewModel = RichChatViewModel()
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
// ACP state
|
||||
private var acpClient: ACPClient?
|
||||
private var acpEventTask: Task<Void, Never>?
|
||||
private var acpPromptTask: Task<Void, Never>?
|
||||
private var healthMonitorTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isHandlingDisconnect = false
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
var acpStatus: String = ""
|
||||
var acpError: String?
|
||||
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
|
||||
/// Shown above the raw error in the UI when present.
|
||||
var acpErrorHint: String?
|
||||
/// Tail of stderr captured from `hermes acp` at the time of the last
|
||||
/// failure — shown in a collapsible details section so users can copy/paste.
|
||||
var acpErrorDetails: String?
|
||||
/// True when `hasAnyAICredential()` returned false at last preflight.
|
||||
var missingCredentials: Bool = false
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
|
||||
var hermesBinaryExists: Bool {
|
||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||
}
|
||||
|
||||
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
|
||||
/// updates `missingCredentials`. Cheap — safe to call from view `.task`.
|
||||
func refreshCredentialPreflight() {
|
||||
missingCredentials = !HermesFileService.hasAnyAICredential()
|
||||
}
|
||||
|
||||
/// Clears the error/hint/details triplet so future failures overwrite
|
||||
/// cleanly instead of stacking on top of stale state.
|
||||
private func clearACPErrorState() {
|
||||
acpError = nil
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
}
|
||||
|
||||
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
|
||||
/// stderr tail the ACP client captured, and logs the failure with a
|
||||
/// site-specific context label. Call on any failure path.
|
||||
@MainActor
|
||||
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("\(context): \(msg)")
|
||||
let stderrTail = await client?.recentStderr ?? ""
|
||||
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
|
||||
acpError = msg
|
||||
acpErrorHint = hint
|
||||
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat"])
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
} else {
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
}
|
||||
|
||||
func resumeSession(_ sessionId: String) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: sessionId)
|
||||
} else {
|
||||
richChatViewModel.setSessionId(sessionId)
|
||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||
}
|
||||
}
|
||||
|
||||
func continueLastSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat", "--continue"])
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
// Find most recent session and resume via ACP
|
||||
Task { @MainActor in
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
if let sessionId {
|
||||
startACPSession(resume: sessionId)
|
||||
} else {
|
||||
startACPSession(resume: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchTerminal(arguments: ["chat", "--continue"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send Message
|
||||
|
||||
func sendText(_ text: String) {
|
||||
if displayMode == .richChat {
|
||||
if let client = acpClient {
|
||||
sendViaACP(client: client, text: text)
|
||||
} else {
|
||||
// Auto-start ACP and send the queued message
|
||||
autoStartACPAndSend(text: text)
|
||||
}
|
||||
} else if let tv = terminalView {
|
||||
sendToTerminal(tv, text: text + "\r")
|
||||
}
|
||||
}
|
||||
|
||||
/// Start ACP for the current or most recent session, then send the queued prompt.
|
||||
private func autoStartACPAndSend(text: String) {
|
||||
// Show the user message immediately
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
|
||||
Task { @MainActor in
|
||||
// Find a session to resume: prefer current sessionId, then most recent
|
||||
var sessionToResume = richChatViewModel.sessionId
|
||||
if sessionToResume == nil {
|
||||
let opened = await dataService.open()
|
||||
if opened {
|
||||
sessionToResume = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
|
||||
let client = ACPClient()
|
||||
self.acpClient = client
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
acpStatus = await client.statusMessage
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
|
||||
hasActiveProcess = true
|
||||
|
||||
let resolvedSessionId: String
|
||||
if let existing = sessionToResume {
|
||||
acpStatus = "Loading session..."
|
||||
do {
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
|
||||
} catch {
|
||||
logger.info("Session \(existing) not found in ACP, creating new session")
|
||||
acpStatus = "Creating new session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
} else {
|
||||
acpStatus = "Creating session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
} catch {
|
||||
acpStatus = "Failed"
|
||||
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
acpError = "No session ID — cannot send"
|
||||
return
|
||||
}
|
||||
|
||||
// Don't duplicate user message if autoStartACPAndSend already added it
|
||||
if richChatViewModel.messages.last?.isUser != true
|
||||
|| richChatViewModel.messages.last?.content != text {
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
}
|
||||
|
||||
acpStatus = "Agent working..."
|
||||
acpPromptTask = Task { @MainActor in
|
||||
do {
|
||||
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
|
||||
acpStatus = "Ready"
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: result)
|
||||
)
|
||||
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||
await richChatViewModel.refreshSessionFromDB()
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
acpStatus = "Error"
|
||||
await recordACPFailure(error, client: client, context: "ACP prompt failed")
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||
stopReason: "error",
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
thoughtTokens: 0, cachedReadTokens: 0
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient()
|
||||
self.acpClient = client
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Start ACP process and event loop FIRST
|
||||
try await client.start()
|
||||
acpStatus = await client.statusMessage
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
hasActiveProcess = true
|
||||
|
||||
let resolvedSessionId: String
|
||||
if let sessionId {
|
||||
acpStatus = "Loading session..."
|
||||
do {
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
|
||||
acpStatus = "Creating new session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
// Load messages from both origin CLI session and ACP session
|
||||
await richChatViewModel.loadSessionHistory(
|
||||
sessionId: sessionId,
|
||||
acpSessionId: resolvedSessionId
|
||||
)
|
||||
} else {
|
||||
acpStatus = "Creating session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Refresh session list so the new ACP session appears in the Resume menu
|
||||
await loadRecentSessions()
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
} catch {
|
||||
acpStatus = "Failed"
|
||||
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startACPEventLoop(client: ACPClient) {
|
||||
acpEventTask = Task { @MainActor [weak self] in
|
||||
let eventStream = await client.events
|
||||
for await event in eventStream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.richChatViewModel.handleACPEvent(event)
|
||||
self?.acpStatus = await client.statusMessage
|
||||
}
|
||||
// Stream ended — if we weren't cancelled, the connection died
|
||||
if !Task.isCancelled {
|
||||
self?.handleConnectionDied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startHealthMonitor(client: ACPClient) {
|
||||
healthMonitorTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
let healthy = await client.isHealthy
|
||||
if !healthy {
|
||||
self?.handleConnectionDied()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectionDied() {
|
||||
guard acpClient != nil, !isHandlingDisconnect else { return }
|
||||
isHandlingDisconnect = true
|
||||
logger.warning("ACP connection died")
|
||||
|
||||
// Finalize any in-progress streaming message before reconnection
|
||||
richChatViewModel.finalizeOnDisconnect()
|
||||
|
||||
// Save session ID for reconnection before cleaning up
|
||||
let savedSessionId = richChatViewModel.sessionId
|
||||
|
||||
// Clean up the dead client
|
||||
acpPromptTask?.cancel()
|
||||
acpPromptTask = nil
|
||||
acpEventTask?.cancel()
|
||||
acpEventTask = nil
|
||||
healthMonitorTask?.cancel()
|
||||
healthMonitorTask = nil
|
||||
if let client = acpClient {
|
||||
Task { await client.stop() }
|
||||
}
|
||||
acpClient = nil
|
||||
hasActiveProcess = false
|
||||
|
||||
// Attempt auto-reconnect if we have a session to restore
|
||||
guard let savedSessionId else {
|
||||
showConnectionFailure()
|
||||
isHandlingDisconnect = false
|
||||
return
|
||||
}
|
||||
attemptReconnect(sessionId: savedSessionId)
|
||||
}
|
||||
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
clearACPErrorState()
|
||||
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for attempt in 1...Self.maxReconnectAttempts {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
|
||||
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
|
||||
|
||||
// Backoff delay (skip on first attempt for fast recovery)
|
||||
if attempt > 1 {
|
||||
let delay = min(
|
||||
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
|
||||
Self.maxReconnectDelay
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
|
||||
let client = ACPClient()
|
||||
do {
|
||||
try await client.start()
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
let resolvedSessionId: String
|
||||
|
||||
// Try resumeSession first (designed for reconnection), then loadSession.
|
||||
// NEVER fall back to newSession — that loses all conversation context.
|
||||
do {
|
||||
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// Success — wire up the new client
|
||||
self.acpClient = client
|
||||
self.hasActiveProcess = true
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
|
||||
// Reconcile in-memory messages with what Hermes persisted to DB
|
||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
|
||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||
clearACPErrorState()
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
isHandlingDisconnect = false
|
||||
logger.info("Reconnected successfully on attempt \(attempt)")
|
||||
return
|
||||
} catch {
|
||||
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
|
||||
await client.stop()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted
|
||||
guard !Task.isCancelled else { return }
|
||||
showConnectionFailure()
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
}
|
||||
|
||||
private func showConnectionFailure() {
|
||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||
acpStatus = "Connection lost"
|
||||
clearACPErrorState()
|
||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||
}
|
||||
|
||||
func stopACP() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
acpPromptTask?.cancel()
|
||||
acpPromptTask = nil
|
||||
acpEventTask?.cancel()
|
||||
acpEventTask = nil
|
||||
healthMonitorTask?.cancel()
|
||||
healthMonitorTask = nil
|
||||
if let client = acpClient {
|
||||
Task { await client.stop() }
|
||||
}
|
||||
acpClient = nil
|
||||
hasActiveProcess = false
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
/// Respond to a permission request from the ACP agent.
|
||||
func respondToPermission(optionId: String) {
|
||||
guard let client = acpClient,
|
||||
let permission = richChatViewModel.pendingPermission else { return }
|
||||
Task {
|
||||
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
|
||||
}
|
||||
richChatViewModel.pendingPermission = nil
|
||||
}
|
||||
|
||||
// MARK: - Recent Sessions
|
||||
|
||||
func loadRecentSessions() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
@@ -55,6 +482,8 @@ final class ChatViewModel {
|
||||
return session.id
|
||||
}
|
||||
|
||||
// MARK: - Voice (terminal mode only)
|
||||
|
||||
func toggleVoice() {
|
||||
guard let tv = terminalView else { return }
|
||||
if voiceEnabled {
|
||||
@@ -76,18 +505,21 @@ final class ChatViewModel {
|
||||
|
||||
func pushToTalk() {
|
||||
guard let tv = terminalView, voiceEnabled else { return }
|
||||
// Ctrl+B = ASCII 0x02
|
||||
let ctrlB: [UInt8] = [0x02]
|
||||
tv.send(source: tv, data: ctrlB[0..<1])
|
||||
isRecording.toggle()
|
||||
}
|
||||
|
||||
// MARK: - Terminal Mode
|
||||
|
||||
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
|
||||
let bytes = Array(text.utf8)
|
||||
tv.send(source: tv, data: bytes[0..<bytes.count])
|
||||
}
|
||||
|
||||
private func launchTerminal(arguments: [String]) {
|
||||
stopACP()
|
||||
|
||||
if let existing = terminalView {
|
||||
existing.terminate()
|
||||
existing.removeFromSuperview()
|
||||
@@ -102,6 +534,7 @@ final class ChatViewModel {
|
||||
self?.hasActiveProcess = false
|
||||
self?.voiceEnabled = false
|
||||
self?.isRecording = false
|
||||
Task { await self?.richChatViewModel.refreshMessages() }
|
||||
})
|
||||
terminal.processDelegate = coord
|
||||
self.coordinator = coord
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatDisplayMode: String, CaseIterable {
|
||||
case terminal
|
||||
case richChat
|
||||
}
|
||||
|
||||
struct MessageGroup: Identifiable {
|
||||
let id: Int
|
||||
let userMessage: HermesMessage?
|
||||
let assistantMessages: [HermesMessage]
|
||||
let toolResults: [String: HermesMessage]
|
||||
|
||||
var allMessages: [HermesMessage] {
|
||||
var result: [HermesMessage] = []
|
||||
if let user = userMessage { result.append(user) }
|
||||
result.append(contentsOf: assistantMessages)
|
||||
return result
|
||||
}
|
||||
|
||||
var toolCallCount: Int {
|
||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class RichChatViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
var currentSession: HermesSession?
|
||||
var messageGroups: [MessageGroup] = []
|
||||
var isAgentWorking = false
|
||||
var pendingPermission: PendingPermission?
|
||||
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||
var scrollTrigger = UUID()
|
||||
|
||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||
private(set) var acpInputTokens = 0
|
||||
private(set) var acpOutputTokens = 0
|
||||
private(set) var acpThoughtTokens = 0
|
||||
private(set) var acpCachedReadTokens = 0
|
||||
|
||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||
private(set) var availableCommandNames: Set<String> = []
|
||||
|
||||
var supportsCompress: Bool { availableCommandNames.contains("compress") }
|
||||
|
||||
var hasMessages: Bool { !messages.isEmpty }
|
||||
|
||||
func requestScrollToBottom() {
|
||||
scrollTrigger = UUID()
|
||||
}
|
||||
|
||||
private(set) var sessionId: String?
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
private(set) var originSessionId: String?
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
private var streamingToolCalls: [HermesToolCall] = []
|
||||
|
||||
// DB polling state (used in terminal mode fallback)
|
||||
private var lastKnownFingerprint: HermesDataService.MessageFingerprint?
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
private var resetTimestamp: Date?
|
||||
private var userSendPending = false
|
||||
private var activePollingTimer: Timer?
|
||||
|
||||
struct PendingPermission {
|
||||
let requestId: Int
|
||||
let title: String
|
||||
let kind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
debounceTask?.cancel()
|
||||
stopActivePolling()
|
||||
Task { await dataService.close() }
|
||||
messages = []
|
||||
messageGroups = []
|
||||
currentSession = nil
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
nextLocalId = -1
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
acpInputTokens = 0
|
||||
acpOutputTokens = 0
|
||||
acpThoughtTokens = 0
|
||||
acpCachedReadTokens = 0
|
||||
availableCommandNames = []
|
||||
pendingPermission = nil
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String?) {
|
||||
sessionId = id
|
||||
lastKnownFingerprint = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
stopActivePolling()
|
||||
debounceTask?.cancel()
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||
func refreshSessionFromDB() async {
|
||||
guard let sessionId else { return }
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
if let session = await dataService.fetchSession(id: sessionId) {
|
||||
currentSession = session
|
||||
}
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
// MARK: - ACP Event Handling
|
||||
|
||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||
func addUserMessage(text: String) {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
let message = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "user",
|
||||
content: text,
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
)
|
||||
messages.append(message)
|
||||
isAgentWorking = true
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Process a streaming ACP event and update the message list.
|
||||
func handleACPEvent(_ event: ACPEvent) {
|
||||
switch event {
|
||||
case .messageChunk(_, let text):
|
||||
appendMessageChunk(text: text)
|
||||
case .thoughtChunk(_, let text):
|
||||
appendThoughtChunk(text: text)
|
||||
case .toolCallStart(_, let call):
|
||||
handleToolCallStart(call)
|
||||
case .toolCallUpdate(_, let update):
|
||||
handleToolCallComplete(update)
|
||||
case .permissionRequest(_, let requestId, let request):
|
||||
pendingPermission = PendingPermission(
|
||||
requestId: requestId,
|
||||
title: request.toolCallTitle,
|
||||
kind: request.toolCallKind,
|
||||
options: request.options
|
||||
)
|
||||
case .promptComplete(_, let response):
|
||||
handlePromptComplete(response: response)
|
||||
case .connectionLost(let reason):
|
||||
handleConnectionLost(reason: reason)
|
||||
case .availableCommands(_, let commands):
|
||||
var names: Set<String> = []
|
||||
for entry in commands {
|
||||
if let name = entry["name"] as? String {
|
||||
// Hermes sends names either as "compress" or "/compress"
|
||||
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
|
||||
}
|
||||
}
|
||||
availableCommandNames = names
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func appendMessageChunk(text: String) {
|
||||
streamingAssistantText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func appendThoughtChunk(text: String) {
|
||||
streamingThinkingText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallStart(_ call: ACPToolCallEvent) {
|
||||
let toolCall = HermesToolCall(
|
||||
callId: call.toolCallId,
|
||||
functionName: call.functionName,
|
||||
arguments: call.argumentsJSON
|
||||
)
|
||||
streamingToolCalls.append(toolCall)
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
|
||||
// Finalize the streaming assistant message (with its tool calls) as a permanent message
|
||||
finalizeStreamingMessage()
|
||||
|
||||
// Add tool result message
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "tool",
|
||||
content: update.rawOutput ?? update.content,
|
||||
toolCallId: update.toolCallId,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
private func handlePromptComplete(response: ACPPromptResult) {
|
||||
finalizeStreamingMessage()
|
||||
// Accumulate token usage from this prompt
|
||||
acpInputTokens += response.inputTokens
|
||||
acpOutputTokens += response.outputTokens
|
||||
acpThoughtTokens += response.thoughtTokens
|
||||
acpCachedReadTokens += response.cachedReadTokens
|
||||
isAgentWorking = false
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
private func handleConnectionLost(reason: String) {
|
||||
finalizeStreamingMessage()
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "system",
|
||||
content: "Connection lost: \(reason). Use the Session menu to start or resume a session.",
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Streaming Message Management
|
||||
|
||||
private static let streamingId = 0
|
||||
|
||||
/// Insert or update the in-progress streaming assistant message (id=0).
|
||||
private func upsertStreamingMessage() {
|
||||
let msg = HermesMessage(
|
||||
id: Self.streamingId,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
|
||||
if let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) {
|
||||
messages[idx] = msg
|
||||
} else {
|
||||
messages.append(msg)
|
||||
}
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
||||
private func finalizeStreamingMessage() {
|
||||
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
|
||||
|
||||
// Only finalize if there's actual content
|
||||
let hasContent = !streamingAssistantText.isEmpty
|
||||
|| !streamingThinkingText.isEmpty
|
||||
|| !streamingToolCalls.isEmpty
|
||||
|
||||
if hasContent {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages[idx] = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
} else {
|
||||
// Remove empty streaming placeholder
|
||||
messages.remove(at: idx)
|
||||
}
|
||||
|
||||
// Reset streaming state for next chunk
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
}
|
||||
|
||||
// MARK: - Disconnect Recovery
|
||||
|
||||
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
||||
/// Saves partial content as a permanent message without adding a system message.
|
||||
func finalizeOnDisconnect() {
|
||||
finalizeStreamingMessage()
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
||||
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
||||
/// that the ACP process may not have persisted before crashing).
|
||||
func reconcileWithDB(sessionId: String) async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
await dataService.close()
|
||||
|
||||
// Find local-only user messages not yet in DB.
|
||||
// Local messages have negative IDs; DB messages have positive IDs.
|
||||
let dbUserContents = Set(dbMessages.filter(\.isUser).map(\.content))
|
||||
let localOnlyMessages = messages.filter { msg in
|
||||
msg.id < 0 && msg.isUser && !dbUserContents.contains(msg.content)
|
||||
}
|
||||
|
||||
// Build reconciled list: DB messages + unmatched local user messages
|
||||
var reconciled = dbMessages
|
||||
for localMsg in localOnlyMessages {
|
||||
if let ts = localMsg.timestamp,
|
||||
let insertIdx = reconciled.firstIndex(where: { ($0.timestamp ?? .distantPast) > ts }) {
|
||||
reconciled.insert(localMsg, at: insertIdx)
|
||||
} else {
|
||||
reconciled.append(localMsg)
|
||||
}
|
||||
}
|
||||
|
||||
messages = reconciled
|
||||
currentSession = session
|
||||
let minId = reconciled.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load History from DB (for resumed sessions)
|
||||
|
||||
/// Load message history from the DB, optionally combining an origin session
|
||||
/// (e.g., CLI session) with the current ACP session.
|
||||
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
self.sessionId = sessionId
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
// and combine them chronologically
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
messages = allMessages
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - DB Polling (terminal mode fallback)
|
||||
|
||||
func markAgentWorking() {
|
||||
isAgentWorking = true
|
||||
userSendPending = true
|
||||
startActivePolling()
|
||||
}
|
||||
|
||||
func scheduleRefresh() {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMessages() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
if sessionId == nil {
|
||||
if let resetTime = resetTimestamp {
|
||||
if let candidate = await dataService.fetchMostRecentlyStartedSessionId(after: resetTime) {
|
||||
sessionId = candidate
|
||||
}
|
||||
}
|
||||
if sessionId == nil {
|
||||
sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
}
|
||||
}
|
||||
|
||||
guard let sessionId else { return }
|
||||
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
messages = fetched
|
||||
currentSession = session
|
||||
buildMessageGroups()
|
||||
|
||||
let derivedWorking = deriveAgentWorking(from: fetched)
|
||||
if userSendPending {
|
||||
if fetched.last?.isUser == true {
|
||||
userSendPending = false
|
||||
}
|
||||
isAgentWorking = true
|
||||
} else {
|
||||
let wasWorking = isAgentWorking
|
||||
isAgentWorking = derivedWorking
|
||||
if wasWorking && !derivedWorking {
|
||||
stopActivePolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startActivePolling() {
|
||||
stopActivePolling()
|
||||
activePollingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopActivePolling() {
|
||||
activePollingTimer?.invalidate()
|
||||
activePollingTimer = nil
|
||||
}
|
||||
|
||||
private func deriveAgentWorking(from fetched: [HermesMessage]) -> Bool {
|
||||
guard let last = fetched.last else { return false }
|
||||
if last.isUser { return true }
|
||||
if last.isToolResult { return true }
|
||||
if last.isAssistant {
|
||||
if !last.toolCalls.isEmpty {
|
||||
let allCallIds = Set(last.toolCalls.map(\.callId))
|
||||
let resultCallIds = Set(fetched.compactMap { $0.isToolResult ? $0.toolCallId : nil })
|
||||
return !allCallIds.subtracting(resultCallIds).isEmpty
|
||||
}
|
||||
return last.finishReason == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Message Grouping
|
||||
|
||||
private func buildMessageGroups() {
|
||||
var groups: [MessageGroup] = []
|
||||
var currentUser: HermesMessage?
|
||||
var currentAssistant: [HermesMessage] = []
|
||||
var currentToolResults: [String: HermesMessage] = [:]
|
||||
var groupIndex = 0
|
||||
|
||||
func flushGroup() {
|
||||
if currentUser != nil || !currentAssistant.isEmpty {
|
||||
// Use stable sequential IDs so SwiftUI doesn't re-create views
|
||||
// when streaming messages finalize (id changes from 0 to -N)
|
||||
groups.append(MessageGroup(
|
||||
id: groupIndex,
|
||||
userMessage: currentUser,
|
||||
assistantMessages: currentAssistant,
|
||||
toolResults: currentToolResults
|
||||
))
|
||||
groupIndex += 1
|
||||
}
|
||||
currentUser = nil
|
||||
currentAssistant = []
|
||||
currentToolResults = [:]
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.isUser {
|
||||
flushGroup()
|
||||
currentUser = message
|
||||
} else if message.isToolResult {
|
||||
if let callId = message.toolCallId {
|
||||
currentToolResults[callId] = message
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
} else {
|
||||
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
|
||||
flushGroup()
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
}
|
||||
}
|
||||
flushGroup()
|
||||
|
||||
messageGroups = groups
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,154 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
terminalArea
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner rendered between the toolbar and the chat area when either
|
||||
/// (a) a preflight credential check failed, or (b) the ACP subprocess
|
||||
/// returned an error we captured. Shows a short hint + expandable raw
|
||||
/// details (stderr tail) that the user can copy to the clipboard.
|
||||
@ViewBuilder
|
||||
private var errorBanner: some View {
|
||||
if let err = viewModel.acpError {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let hint = viewModel.acpErrorHint {
|
||||
Text(hint)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(showErrorDetails ? nil : 2)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.acpErrorDetails != nil {
|
||||
Button(showErrorDetails ? "Hide details" : "Show details") {
|
||||
showErrorDetails.toggle()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button {
|
||||
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: "\n\n")
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(payload, forType: .string)
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Copy error details")
|
||||
}
|
||||
if showErrorDetails, let details = viewModel.acpErrorDetails {
|
||||
ScrollView {
|
||||
Text(details)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No AI provider credentials detected")
|
||||
.font(.callout)
|
||||
Text("Add `ANTHROPIC_API_KEY` (or similar) to `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.orange.opacity(0.08))
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.25))
|
||||
.frame(height: 1),
|
||||
alignment: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "terminal")
|
||||
Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if viewModel.hasActiveProcess {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Active")
|
||||
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else if let error = viewModel.acpError {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
.help(error)
|
||||
if let sid = viewModel.richChatViewModel.sessionId {
|
||||
Button("Reconnect") {
|
||||
viewModel.resumeSession(sid)
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if !viewModel.acpStatus.isEmpty {
|
||||
Circle()
|
||||
.fill(.yellow)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(viewModel.acpStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.secondary)
|
||||
@@ -40,10 +162,21 @@ struct ChatView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.hasActiveProcess {
|
||||
if viewModel.hasActiveProcess && viewModel.displayMode == .terminal {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||
Image(systemName: "terminal")
|
||||
.help("Terminal")
|
||||
.tag(ChatDisplayMode.terminal)
|
||||
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||
.help("Rich Chat")
|
||||
.tag(ChatDisplayMode.richChat)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
|
||||
if !viewModel.hermesBinaryExists {
|
||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
@@ -51,6 +184,12 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
|
||||
Button("Return to Active Session (\(activeId.prefix(8))...)") {
|
||||
viewModel.richChatViewModel.requestScrollToBottom()
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("New Session") {
|
||||
viewModel.startNewSession()
|
||||
}
|
||||
@@ -60,6 +199,8 @@ struct ChatView: View {
|
||||
if !viewModel.recentSessions.isEmpty {
|
||||
Divider()
|
||||
Text("Resume Session")
|
||||
let activeSessionId = viewModel.richChatViewModel.sessionId
|
||||
let originSessionId = viewModel.richChatViewModel.originSessionId
|
||||
ForEach(viewModel.recentSessions) { session in
|
||||
Button {
|
||||
viewModel.resumeSession(session.id)
|
||||
@@ -75,6 +216,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(session.id == activeSessionId || session.id == originSessionId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -137,6 +279,16 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chatArea: some View {
|
||||
switch viewModel.displayMode {
|
||||
case .terminal:
|
||||
terminalArea
|
||||
case .richChat:
|
||||
richChatArea
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var terminalArea: some View {
|
||||
if let terminal = viewModel.terminalView {
|
||||
@@ -157,4 +309,119 @@ struct ChatView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var richChatArea: some View {
|
||||
ZStack {
|
||||
// Keep terminal alive in background if it exists (terminal mode session)
|
||||
if let terminal = viewModel.terminalView {
|
||||
PersistentTerminalView(terminalView: terminal)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if viewModel.hermesBinaryExists {
|
||||
RichChatView(
|
||||
richChat: viewModel.richChatViewModel,
|
||||
onSend: { viewModel.sendText($0) },
|
||||
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Hermes Not Found",
|
||||
systemImage: "terminal",
|
||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
// Permission approval sheet
|
||||
.sheet(item: permissionBinding) { permission in
|
||||
PermissionApprovalView(
|
||||
title: permission.title,
|
||||
kind: permission.kind,
|
||||
options: permission.options,
|
||||
onRespond: { optionId in
|
||||
viewModel.respondToPermission(optionId: optionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
|
||||
Binding(
|
||||
get: { viewModel.richChatViewModel.pendingPermission },
|
||||
set: { viewModel.richChatViewModel.pendingPermission = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
|
||||
var id: Int { requestId }
|
||||
}
|
||||
|
||||
struct PermissionApprovalView: View {
|
||||
let title: String
|
||||
let kind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
let onRespond: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: kindIcon)
|
||||
.font(.title)
|
||||
.foregroundStyle(kindColor)
|
||||
|
||||
Text("Tool Approval Required")
|
||||
.font(.headline)
|
||||
|
||||
Text(title)
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(options, id: \.optionId) { option in
|
||||
if option.optionId == "deny" {
|
||||
Button(option.name) {
|
||||
onRespond(option.optionId)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(option.name) {
|
||||
onRespond(option.optionId)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 350)
|
||||
}
|
||||
|
||||
private var kindIcon: String {
|
||||
switch kind {
|
||||
case "execute": return "terminal"
|
||||
case "edit": return "pencil"
|
||||
case "delete": return "trash"
|
||||
default: return "wrench"
|
||||
}
|
||||
}
|
||||
|
||||
private var kindColor: Color {
|
||||
switch kind {
|
||||
case "execute": return .orange
|
||||
case "edit": return .blue
|
||||
case "delete": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct CodeBlockView: View {
|
||||
let code: String
|
||||
let language: String?
|
||||
|
||||
@State private var copied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let language, !language.isEmpty {
|
||||
HStack {
|
||||
Text(language)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
copyButton
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
copyButton
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.background(Color(nsColor: NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var copyButton: some View {
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(code, forType: .string)
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
copied = false
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(copied ? .green : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Copy code")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
let isEnabled: Bool
|
||||
var supportsCompress: Bool = false
|
||||
|
||||
@State private var text = ""
|
||||
@State private var showCompressSheet = false
|
||||
@State private var compressFocus = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if supportsCompress {
|
||||
Button {
|
||||
compressFocus = ""
|
||||
showCompressSheet = true
|
||||
} label: {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.help("Compress conversation (/compress)")
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isFocused)
|
||||
.frame(minHeight: 28, maxHeight: 120)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text("Message Hermes...")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.shift) {
|
||||
return .ignored
|
||||
}
|
||||
send()
|
||||
return .handled
|
||||
}
|
||||
|
||||
Button {
|
||||
send()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSend)
|
||||
.help("Send message (Enter)")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
.sheet(isPresented: $showCompressSheet) {
|
||||
compressSheet
|
||||
}
|
||||
}
|
||||
|
||||
private var compressSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Compress Conversation")
|
||||
.font(.headline)
|
||||
Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Focus topic (optional)", text: $compressFocus)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showCompressSheet = false }
|
||||
Button("Compress") {
|
||||
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
||||
onSend(command)
|
||||
showCompressSheet = false
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, isEnabled else { return }
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
let isWorking: Bool
|
||||
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||
var scrollTrigger: UUID = UUID()
|
||||
|
||||
/// Track the last group's assistant content length to detect streaming updates.
|
||||
private var scrollAnchor: String {
|
||||
if isWorking { return "typing-indicator" }
|
||||
if let last = groups.last { return "group-\(last.id)" }
|
||||
return "scroll-top"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
.id("scroll-top")
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group)
|
||||
.id("group-\(group.id)")
|
||||
}
|
||||
|
||||
if isWorking {
|
||||
typingIndicator
|
||||
.id("typing-indicator")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
// Scroll to bottom when view first appears with content
|
||||
.onAppear {
|
||||
if !groups.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll on new groups
|
||||
.onChange(of: groups.count) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll when agent starts/stops working
|
||||
.onChange(of: isWorking) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on streaming content updates (group content changes)
|
||||
.onChange(of: scrollAnchor) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on last message content change (streaming text)
|
||||
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
// Scroll on tool call count change
|
||||
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on external trigger (e.g., "Return to Active Session" button)
|
||||
.onChange(of: scrollTrigger) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
|
||||
let target = scrollAnchor
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
} else {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private var typingIndicator: some View {
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
Circle()
|
||||
.fill(.secondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(0.6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.symbolEffect(.pulse)
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageGroupView: View {
|
||||
let group: MessageGroup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let user = group.userMessage {
|
||||
RichMessageBubble(message: user, toolResults: [:])
|
||||
}
|
||||
|
||||
ForEach(group.assistantMessages.filter(\.isAssistant)) { message in
|
||||
RichMessageBubble(message: message, toolResults: group.toolResults)
|
||||
}
|
||||
|
||||
if group.toolCallCount > 1 {
|
||||
toolSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolSummary: some View {
|
||||
let kinds = toolKindCounts
|
||||
if !kinds.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(summaryText(kinds))
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolKindCounts: [ToolKind: Int] {
|
||||
var counts: [ToolKind: Int] = [:]
|
||||
for msg in group.assistantMessages where msg.isAssistant {
|
||||
for call in msg.toolCalls {
|
||||
counts[call.toolKind, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
||||
let total = kinds.values.reduce(0, +)
|
||||
let parts = kinds.sorted(by: { $0.value > $1.value })
|
||||
.map { "\($0.value) \($0.key.rawValue)" }
|
||||
.joined(separator: ", ")
|
||||
return "Used \(total) tools (\(parts))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatView: View {
|
||||
@Bindable var richChat: RichChatViewModel
|
||||
var onSend: (String) -> Void
|
||||
var isEnabled: Bool
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
SessionInfoBar(
|
||||
session: richChat.currentSession,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
acpInputTokens: richChat.acpInputTokens,
|
||||
acpOutputTokens: richChat.acpOutputTokens,
|
||||
acpThoughtTokens: richChat.acpThoughtTokens
|
||||
)
|
||||
Divider()
|
||||
|
||||
if richChat.messageGroups.isEmpty && !richChat.isAgentWorking {
|
||||
ContentUnavailableView(
|
||||
"Chat Messages",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
description: Text("Messages will appear here as the conversation progresses.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
RichChatMessageList(
|
||||
groups: richChat.messageGroups,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
scrollTrigger: richChat.scrollTrigger
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
RichChatInputBar(
|
||||
onSend: { text in
|
||||
onSend(text)
|
||||
},
|
||||
isEnabled: isEnabled,
|
||||
supportsCompress: richChat.supportsCompress
|
||||
)
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
richChat.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichMessageBubble: View {
|
||||
let message: HermesMessage
|
||||
let toolResults: [String: HermesMessage]
|
||||
|
||||
var body: some View {
|
||||
if message.isUser {
|
||||
userBubble
|
||||
} else if message.isAssistant {
|
||||
assistantBubble
|
||||
}
|
||||
// Tool result messages are rendered inline in ToolCallCard, not as standalone bubbles
|
||||
}
|
||||
|
||||
// MARK: - User Bubble
|
||||
|
||||
private var userBubble: some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
HStack {
|
||||
Spacer(minLength: 80)
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text(time, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
||||
// MARK: - Assistant Bubble
|
||||
|
||||
private var assistantBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if message.hasReasoning {
|
||||
reasoningSection
|
||||
}
|
||||
|
||||
if !message.content.isEmpty {
|
||||
contentView
|
||||
}
|
||||
|
||||
if !message.toolCalls.isEmpty {
|
||||
toolCallsSection
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
|
||||
metadataFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Content Rendering
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
let blocks = parseContentBlocks(message.content)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .text(let text):
|
||||
MarkdownContentView(content: text)
|
||||
case .code(let code, let language):
|
||||
CodeBlockView(code: code, language: language)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reasoning
|
||||
|
||||
private var reasoningSection: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.reasoning ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Reasoning")
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("(\(tokens) tokens)")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
// MARK: - Tool Calls
|
||||
|
||||
private var toolCallsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(message.toolCalls) { call in
|
||||
ToolCallCard(
|
||||
call: call,
|
||||
result: toolResults[call.callId]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Footer
|
||||
|
||||
private var metadataFooter: some View {
|
||||
HStack(spacing: 8) {
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("\(tokens) tokens")
|
||||
}
|
||||
if let reason = message.finishReason, !reason.isEmpty {
|
||||
Text(reason)
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text(time, style: .time)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Parsing
|
||||
|
||||
private enum ContentBlock {
|
||||
case text(String)
|
||||
case code(String, String?)
|
||||
}
|
||||
|
||||
private func parseContentBlocks(_ content: String) -> [ContentBlock] {
|
||||
var blocks: [ContentBlock] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
var currentText: [String] = []
|
||||
var currentCode: [String] = []
|
||||
var codeLanguage: String?
|
||||
var inCode = false
|
||||
|
||||
for line in lines {
|
||||
if !inCode && line.hasPrefix("```") {
|
||||
if !currentText.isEmpty {
|
||||
blocks.append(.text(currentText.joined(separator: "\n")))
|
||||
currentText = []
|
||||
}
|
||||
inCode = true
|
||||
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
codeLanguage = lang.isEmpty ? nil : lang
|
||||
} else if inCode && line.hasPrefix("```") {
|
||||
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
|
||||
currentCode = []
|
||||
codeLanguage = nil
|
||||
inCode = false
|
||||
} else if inCode {
|
||||
currentCode.append(line)
|
||||
} else {
|
||||
currentText.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
if inCode && !currentCode.isEmpty {
|
||||
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
|
||||
}
|
||||
if !currentText.isEmpty {
|
||||
let text = currentText.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
blocks.append(.text(text))
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionInfoBar: View {
|
||||
let session: HermesSession?
|
||||
let isWorking: Bool
|
||||
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
|
||||
var acpInputTokens: Int = 0
|
||||
var acpOutputTokens: Int = 0
|
||||
var acpThoughtTokens: Int = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let session {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isWorking ? .green : .secondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(isWorking ? 1 : 0.6)
|
||||
if isWorking {
|
||||
Text("Working")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
if let title = session.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
if let model = session.model {
|
||||
Label(model, systemImage: "cpu")
|
||||
}
|
||||
|
||||
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
|
||||
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
|
||||
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
|
||||
.contentTransition(.numericText())
|
||||
|
||||
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
|
||||
if reasonToks > 0 {
|
||||
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
|
||||
}
|
||||
|
||||
if let cost = session.displayCostUSD {
|
||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
if let start = session.startedAt {
|
||||
Label {
|
||||
Text(start, style: .relative)
|
||||
.monospacedDigit()
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Label(session.source, systemImage: session.sourceIcon)
|
||||
} else {
|
||||
Text("No active session")
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToolCallCard: View {
|
||||
let call: HermesToolCall
|
||||
let result: HermesMessage?
|
||||
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(toolColor)
|
||||
.frame(width: 3, height: 16)
|
||||
|
||||
Image(systemName: call.toolKind.icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(toolColor)
|
||||
|
||||
Text(call.functionName)
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(call.argumentsSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
if result != nil {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
if expanded {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if !call.arguments.isEmpty && call.arguments != "{}" {
|
||||
Text("Arguments")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatJSON(call.arguments))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
if let result, !result.content.isEmpty {
|
||||
Text("Result")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
ToolResultContent(content: result.content)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var toolColor: Color {
|
||||
switch call.toolKind {
|
||||
case .read: return .green
|
||||
case .edit: return .blue
|
||||
case .execute: return .orange
|
||||
case .fetch: return .purple
|
||||
case .browser: return .indigo
|
||||
case .other: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func formatJSON(_ raw: String) -> String {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return raw
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolResultContent: View {
|
||||
let content: String
|
||||
|
||||
@State private var showAll = false
|
||||
|
||||
private var lines: [String] { content.components(separatedBy: "\n") }
|
||||
private var isLong: Bool { lines.count > 8 }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
if isLong {
|
||||
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
|
||||
withAnimation { showAll.toggle() }
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A single pooled credential for a provider (rotation entry).
|
||||
struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
var id: String { "\(provider):\(index):\(internalID)" }
|
||||
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
|
||||
let provider: String
|
||||
let index: Int // 0-based index in the provider's pool
|
||||
let label: String // Human label ("OPENROUTER_API_KEY")
|
||||
let authType: String // "api_key" | "oauth"
|
||||
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
struct HermesCredentialPool: Identifiable, Sendable {
|
||||
var id: String { provider }
|
||||
let provider: String
|
||||
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
|
||||
let credentials: [HermesCredential]
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class CredentialPoolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
|
||||
|
||||
var pools: [HermesCredentialPool] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
|
||||
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
|
||||
/// can extract the authorization URL, pop it open with an explicit button,
|
||||
/// and feed the code back via stdin. See OAuthFlowController for why we
|
||||
/// moved off the embedded-terminal approach.
|
||||
let oauthFlow = OAuthFlowController()
|
||||
var oauthProvider: String = ""
|
||||
/// Convenience — the sheet keys a lot of UI off "is the flow running?".
|
||||
var oauthInProgress: Bool { oauthFlow.isRunning }
|
||||
|
||||
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
|
||||
|
||||
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
|
||||
/// output is fragile — the JSON file is structured, stable, and already stores
|
||||
/// exactly the pool data the UI needs. We never display full tokens.
|
||||
func load() {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let authPath = HermesPaths.home + "/auth.json"
|
||||
let strategies = parseStrategies()
|
||||
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: authPath)) else {
|
||||
pools = []
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(AuthFile.self, from: data)
|
||||
pools = Self.buildPools(from: decoded, strategies: strategies)
|
||||
} catch {
|
||||
logger.error("Failed to decode auth.json: \(error.localizedDescription)")
|
||||
pools = []
|
||||
}
|
||||
}
|
||||
|
||||
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
|
||||
private func parseStrategies() -> [String: String] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [:] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
return parsed.maps["credential_pool_strategies"] ?? [:]
|
||||
}
|
||||
|
||||
private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
|
||||
auth.credential_pool.keys.sorted().map { provider in
|
||||
let entries = auth.credential_pool[provider] ?? []
|
||||
let creds = entries.enumerated().map { index, entry in
|
||||
HermesCredential(
|
||||
internalID: entry.id ?? "",
|
||||
provider: provider,
|
||||
index: index,
|
||||
label: entry.label ?? entry.source ?? "",
|
||||
authType: entry.auth_type ?? "",
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
provider: provider,
|
||||
strategy: strategies[provider] ?? "fill_first",
|
||||
credentials: creds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
private static func tail(of token: String) -> String {
|
||||
guard token.count >= 4 else { return "" }
|
||||
return "…" + String(token.suffix(4))
|
||||
}
|
||||
|
||||
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
|
||||
|
||||
func setStrategy(_ strategy: String, for provider: String) {
|
||||
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
|
||||
if result.exitCode == 0 {
|
||||
message = "Strategy updated for \(provider)"
|
||||
load()
|
||||
} else {
|
||||
message = "Failed to update strategy"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an API-key credential to a provider's pool. Runs non-interactively.
|
||||
///
|
||||
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
|
||||
/// Without `--type`, hermes falls back to the provider's default (OAuth for
|
||||
/// Anthropic, etc.) and launches the browser flow even though the user
|
||||
/// just gave us a key.
|
||||
func addAPIKey(provider: String, apiKey: String, label: String) {
|
||||
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
let result = runHermes(args)
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential added"
|
||||
load()
|
||||
} else {
|
||||
logger.warning("Add credential failed: \(result.output)")
|
||||
message = "Add failed: \(result.output.prefix(160))"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
|
||||
/// we can detect the authorization URL from hermes's output, open the
|
||||
/// browser ourselves, and feed the code back via stdin — avoiding the
|
||||
/// subprocess-can't-open-browser problem SwiftTerm had.
|
||||
func startOAuth(provider: String, label: String) {
|
||||
guard !provider.isEmpty else { return }
|
||||
oauthProvider = provider
|
||||
|
||||
oauthFlow.onExit = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.message = self.oauthFlow.succeeded
|
||||
? "OAuth login succeeded"
|
||||
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
|
||||
// Reload regardless — hermes may have written a partial credential
|
||||
// even on a soft failure, and we want the list to reflect truth.
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
oauthFlow.start(provider: provider, label: label)
|
||||
}
|
||||
|
||||
/// Submit the authorization code the user pasted into the form's text
|
||||
/// field. Writes it to hermes's stdin.
|
||||
func submitOAuthCode(_ code: String) {
|
||||
oauthFlow.submitCode(code)
|
||||
}
|
||||
|
||||
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
|
||||
func cancelOAuth() {
|
||||
oauthFlow.stop()
|
||||
}
|
||||
|
||||
func removeCredential(provider: String, index: Int) {
|
||||
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
|
||||
// stored `index` is 0-based, so add 1 when handing to the CLI.
|
||||
let result = runHermes(["auth", "remove", provider, String(index + 1)])
|
||||
if result.exitCode == 0 {
|
||||
message = "Credential removed"
|
||||
load()
|
||||
} else {
|
||||
message = "Remove failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func resetProvider(_ provider: String) {
|
||||
let result = runHermes(["auth", "reset", provider])
|
||||
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
|
||||
load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - auth.json decoding
|
||||
// Shape verified against a real `~/.hermes/auth.json` — see sample in plan notes.
|
||||
// All fields are optional because the format evolves and we want decoding to
|
||||
// succeed even if hermes adds new keys or omits some for certain auth types.
|
||||
|
||||
private struct AuthFile: Decodable {
|
||||
let credential_pool: [String: [AuthEntry]]
|
||||
}
|
||||
|
||||
private struct AuthEntry: Decodable {
|
||||
let id: String?
|
||||
let label: String?
|
||||
let auth_type: String?
|
||||
let source: String?
|
||||
let access_token: String?
|
||||
let last_status: String?
|
||||
let request_count: Int?
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
|
||||
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
|
||||
/// have two problems:
|
||||
///
|
||||
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
|
||||
/// open the user's browser — the macOS `open` command can fail silently
|
||||
/// depending on how the parent app was launched.
|
||||
/// 2. Even when it works, users can't easily copy the URL from a terminal
|
||||
/// emulator to click or share.
|
||||
///
|
||||
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
|
||||
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
|
||||
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
|
||||
/// a code input text field. Submitting writes the code + newline to hermes's
|
||||
/// stdin pipe, which Python's `input()` reads normally — verified in shell
|
||||
/// testing that hermes accepts piped stdin when a TTY isn't available.
|
||||
///
|
||||
/// Hermes exits 0 even on "login did not return credentials" failures, so we
|
||||
/// detect success by scanning output for failure markers AND by letting the
|
||||
/// calling VM reload `auth.json` to see whether a new credential actually
|
||||
/// landed.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class OAuthFlowController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
/// Accumulated terminal output for display. Grows monotonically during
|
||||
/// the flow; cleared on `start(...)`.
|
||||
var output: String = ""
|
||||
|
||||
/// Authorization URL extracted from hermes's output. Shown as a prominent
|
||||
/// "Open in Browser" button once detected.
|
||||
var authorizationURL: String?
|
||||
|
||||
/// True once hermes has printed the "Authorization code:" prompt. Gates
|
||||
/// the code submit button so users can't submit too early.
|
||||
var awaitingCode: Bool = false
|
||||
|
||||
/// True between `start(...)` and process termination.
|
||||
var isRunning: Bool = false
|
||||
|
||||
/// Set when the process exits with a success signal (both zero exit AND
|
||||
/// no failure marker in output). The VM checks this + reloads auth.json.
|
||||
var succeeded: Bool = false
|
||||
|
||||
/// Human-readable error message if start/submit failed mid-flow.
|
||||
var errorMessage: String?
|
||||
|
||||
/// Fired when the process exits, with the raw exit code. Use this to
|
||||
/// trigger a UI reload or close the sheet.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
|
||||
func start(provider: String, label: String) {
|
||||
stop()
|
||||
|
||||
output = ""
|
||||
authorizationURL = nil
|
||||
awaitingCode = false
|
||||
succeeded = false
|
||||
errorMessage = nil
|
||||
|
||||
// Pass --no-browser so hermes doesn't try (and potentially fail) to
|
||||
// launch the browser itself — we do it explicitly with the button.
|
||||
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedLabel.isEmpty {
|
||||
args += ["--label", trimmedLabel]
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = args
|
||||
proc.environment = HermesFileService.enrichedEnvironment()
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
|
||||
// but diagnostic messages can land on stderr; we want both interleaved
|
||||
// in display order.
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = outPipe
|
||||
proc.standardInput = inPipe
|
||||
|
||||
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF — the peer closed its write end. Drop the handler so
|
||||
// Foundation doesn't keep calling us with empty reads.
|
||||
handle.readabilityHandler = nil
|
||||
return
|
||||
}
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
// Hop onto the main actor to mutate observable state.
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleOutputChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] p in
|
||||
let code = p.terminationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
self?.handleTermination(exitCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
process = proc
|
||||
stdinPipe = inPipe
|
||||
stdoutPipe = outPipe
|
||||
isRunning = true
|
||||
} catch {
|
||||
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
isRunning = false
|
||||
awaitingCode = false
|
||||
}
|
||||
|
||||
/// Send the authorization code to hermes's stdin. Called when the user
|
||||
/// taps "Submit" in the sheet's code input field.
|
||||
func submitCode(_ code: String) {
|
||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
errorMessage = "Authorization code is empty"
|
||||
return
|
||||
}
|
||||
guard let stdinPipe else {
|
||||
errorMessage = "Process is no longer accepting input"
|
||||
return
|
||||
}
|
||||
let payload = trimmed + "\n"
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
errorMessage = "Could not encode code"
|
||||
return
|
||||
}
|
||||
do {
|
||||
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||
// After writing, we don't close stdin — hermes might prompt again
|
||||
// on failure. Instead we flip `awaitingCode` off so the UI can
|
||||
// dim the submit button until another prompt appears.
|
||||
awaitingCode = false
|
||||
} catch {
|
||||
errorMessage = "Failed to send code: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly open the detected authorization URL in the default browser.
|
||||
/// Does nothing if no URL has been detected yet.
|
||||
func openURLInBrowser() {
|
||||
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
|
||||
// MARK: - Output handling
|
||||
|
||||
private func handleOutputChunk(_ chunk: String) {
|
||||
output += chunk
|
||||
|
||||
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
|
||||
authorizationURL = url
|
||||
// Auto-open the browser on first detection, since that's what a
|
||||
// well-behaved hermes would have done. We keep the manual button
|
||||
// available for retries / copy-paste.
|
||||
if let parsed = URL(string: url) {
|
||||
NSWorkspace.shared.open(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt may arrive in the same chunk as the URL. Checking
|
||||
// cumulative output (rather than just this chunk) is safer.
|
||||
if !awaitingCode, output.contains("Authorization code:") {
|
||||
awaitingCode = true
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
isRunning = false
|
||||
// Hermes exits 0 even on "login did not return credentials" — detect
|
||||
// that failure marker explicitly so we don't report false success.
|
||||
let failureMarkers = [
|
||||
"did not return credentials",
|
||||
"Token exchange failed",
|
||||
"OAuth login failed",
|
||||
"HTTP Error"
|
||||
]
|
||||
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
|
||||
succeeded = exitCode == 0 && !outputFailed
|
||||
if !succeeded, errorMessage == nil {
|
||||
if outputFailed {
|
||||
errorMessage = "OAuth did not complete — check the output above for details"
|
||||
} else if exitCode != 0 {
|
||||
errorMessage = "hermes exited with code \(exitCode)"
|
||||
}
|
||||
}
|
||||
onExit?(exitCode)
|
||||
}
|
||||
|
||||
// MARK: - URL extraction
|
||||
|
||||
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
|
||||
/// it on its own line in a Rich-rendered box; we want a plain https URL
|
||||
/// that looks like a provider OAuth endpoint.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. URLs containing `client_id=` — real OAuth auth URLs always have this.
|
||||
/// 2. URLs containing `/authorize` — fallback for providers that don't
|
||||
/// include client_id in the query (unusual but possible).
|
||||
/// 3. URLs containing `/oauth/` — last resort.
|
||||
///
|
||||
/// Docs URLs and generic callback URLs are filtered out by these checks.
|
||||
nonisolated static func extractAuthURL(from text: String) -> String? {
|
||||
let pattern = #"https://[^\s\)\]\"'`<>]+"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
|
||||
Range(match.range, in: text).map { String(text[$0]) }
|
||||
}
|
||||
// Prefer the strongest signal so we don't accidentally surface the
|
||||
// redirect callback URL when both appear unencoded in output.
|
||||
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
|
||||
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CredentialPoolsView: View {
|
||||
@State private var viewModel = CredentialPoolsViewModel()
|
||||
@State private var showAddSheet = false
|
||||
@State private var pendingRemove: HermesCredential?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
safetyNotice
|
||||
if viewModel.isLoading {
|
||||
ProgressView().padding()
|
||||
} else if viewModel.pools.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(viewModel.pools) { pool in
|
||||
poolSection(pool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Credential Pools")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
AddCredentialSheet(viewModel: viewModel) {
|
||||
showAddSheet = false
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
|
||||
) {
|
||||
Button("Remove", role: .destructive) {
|
||||
if let target = pendingRemove {
|
||||
viewModel.removeCredential(provider: target.provider, index: target.index)
|
||||
}
|
||||
pendingRemove = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRemove = nil }
|
||||
} message: {
|
||||
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Label("Add Credential", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var safetyNotice: some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "lock.shield")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "key.horizontal")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No credential pools configured")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func poolSection(_ pool: HermesCredentialPool) -> some View {
|
||||
SettingsSection(title: pool.provider, icon: "key.horizontal") {
|
||||
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
|
||||
viewModel.setStrategy(strategy, for: pool.provider)
|
||||
}
|
||||
ForEach(pool.credentials) { cred in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("#\(cred.index + 1)")
|
||||
.font(.system(.caption, design: .monospaced, weight: .bold))
|
||||
if !cred.label.isEmpty {
|
||||
Text(cred.label).font(.caption)
|
||||
}
|
||||
if !cred.authType.isEmpty {
|
||||
Text(cred.authType)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if !cred.lastStatus.isEmpty {
|
||||
Text(cred.lastStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
if !cred.source.isEmpty {
|
||||
Text(cred.source)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if cred.requestCount > 0 {
|
||||
Text("\(cred.requestCount) req")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button("Remove", role: .destructive) { pendingRemove = cred }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "ok", "active": return .green
|
||||
case "cooldown": return .orange
|
||||
case "exhausted": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
/// 1. Provider picker (populated from the models catalog, falls back to free text)
|
||||
/// + type selector (API Key vs OAuth) + optional label
|
||||
/// 2. Either an immediate save (API key) or an embedded terminal running the
|
||||
/// OAuth flow so the user can paste the authorization code back.
|
||||
private struct AddCredentialSheet: View {
|
||||
@Bindable var viewModel: CredentialPoolsViewModel
|
||||
let onDismiss: () -> Void
|
||||
|
||||
enum AuthType: String, CaseIterable, Identifiable {
|
||||
case apiKey = "API Key"
|
||||
case oauth = "OAuth"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var providerID: String = ""
|
||||
@State private var authType: AuthType = .apiKey
|
||||
@State private var apiKey: String = ""
|
||||
@State private var label: String = ""
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
|
||||
private let catalog = ModelCatalogService()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add Credential")
|
||||
.font(.headline)
|
||||
if !oauthStarted {
|
||||
configSection
|
||||
} else {
|
||||
oauthSection
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 600, minHeight: 460)
|
||||
.onAppear {
|
||||
providers = catalog.loadProviders()
|
||||
}
|
||||
// Auto-close the sheet once a credential is actually saved. We key
|
||||
// off `succeeded` which the controller sets only when hermes exited
|
||||
// zero AND the output has no failure markers. The 0.8s delay lets the
|
||||
// user see the success banner before the sheet disappears.
|
||||
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
|
||||
guard newValue else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: provider + type + label + optional API key
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Provider").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
// Free-text first so providers missing from the catalog
|
||||
// (e.g. "nous") are still addable.
|
||||
TextField("e.g. anthropic", text: $providerID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Menu("Browse") {
|
||||
ForEach(providers) { provider in
|
||||
Button(provider.providerName + " (\(provider.providerID))") {
|
||||
providerID = provider.providerID
|
||||
}
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("", selection: $authType) {
|
||||
ForEach(AuthType.allCases) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
|
||||
TextField("e.g. team-prod", text: $label)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if authType == .apiKey {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("API Key").font(.caption).foregroundStyle(.secondary)
|
||||
SecureField("sk-…", text: $apiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
} else {
|
||||
oauthPreamble
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||
/// expectations about the embedded-terminal flow so the browser window
|
||||
/// and code-paste step aren't surprises.
|
||||
private var oauthPreamble: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
// MARK: - Step 2: OAuth — URL button, code field, live output log
|
||||
|
||||
private var oauthSection: some View {
|
||||
// Pull the observable controller into a local so the view redraws
|
||||
// when its @Observable properties change.
|
||||
let flow = viewModel.oauthFlow
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
oauthHeader(flow: flow)
|
||||
urlBlock(flow: flow)
|
||||
codeEntryBlock(flow: flow)
|
||||
outputLogBlock(flow: flow)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func oauthHeader(flow: OAuthFlowController) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.badge.key")
|
||||
Text("OAuth login for \(viewModel.oauthProvider)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if flow.isRunning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if flow.succeeded {
|
||||
Label("Succeeded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else if let err = flow.errorMessage {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization URL block. Hermes prints the URL on startup; we detect
|
||||
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
|
||||
/// showing even after the browser is opened so users can paste it into
|
||||
/// a different browser profile if needed.
|
||||
@ViewBuilder
|
||||
private func urlBlock(flow: OAuthFlowController) -> some View {
|
||||
if let url = flow.authorizationURL {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Authorization URL", systemImage: "link")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(url)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
Spacer()
|
||||
Button {
|
||||
flow.openURLInBrowser()
|
||||
} label: {
|
||||
Label("Open in Browser", systemImage: "safari")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(.blue.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else if flow.isRunning {
|
||||
// Still waiting for hermes to print the URL — usually <1s.
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for authorization URL…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization code input. Only active once hermes has printed its
|
||||
/// "Authorization code:" prompt so users can't submit before hermes is
|
||||
/// ready to receive input.
|
||||
@ViewBuilder
|
||||
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Authorization Code", systemImage: "keyboard")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
TextField("Paste code here…", text: $authCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.disabled(!flow.awaitingCode)
|
||||
.onSubmit { submitCode(flow: flow) }
|
||||
Button("Submit") { submitCode(flow: flow) }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
if !flow.awaitingCode && flow.isRunning {
|
||||
Text("Waiting for hermes to prompt for the code…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Live output log — useful for diagnostics if the flow stalls or errors.
|
||||
@ViewBuilder
|
||||
private func outputLogBlock(flow: OAuthFlowController) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Output", systemImage: "text.alignleft")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
private func submitCode(flow: OAuthFlowController) {
|
||||
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
viewModel.submitOAuthCode(trimmed)
|
||||
authCode = ""
|
||||
}
|
||||
|
||||
// MARK: - Footer (buttons)
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
if oauthStarted {
|
||||
Button("Close") {
|
||||
// Closing mid-flow terminates hermes so we don't leave a
|
||||
// zombie process waiting for stdin forever.
|
||||
viewModel.cancelOAuth()
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
Button("Cancel") { onDismiss() }
|
||||
if authType == .apiKey {
|
||||
Button("Add") {
|
||||
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
|
||||
onDismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} else {
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,101 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class CronViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var jobs: [HermesCronJob] = []
|
||||
var selectedJob: HermesCronJob?
|
||||
var jobOutput: String?
|
||||
var availableSkills: [String] = []
|
||||
var message: String?
|
||||
var showCreateSheet = false
|
||||
var editingJob: HermesCronJob?
|
||||
|
||||
func load() {
|
||||
jobs = fileService.loadCronJobs()
|
||||
availableSkills = fileService.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
|
||||
if let selected = selectedJob, let refreshed = jobs.first(where: { $0.id == selected.id }) {
|
||||
selectedJob = refreshed
|
||||
jobOutput = fileService.loadCronOutput(jobId: refreshed.id)
|
||||
}
|
||||
}
|
||||
|
||||
func selectJob(_ job: HermesCronJob) {
|
||||
selectedJob = job
|
||||
jobOutput = fileService.loadCronOutput(jobId: job.id)
|
||||
}
|
||||
|
||||
// MARK: - CLI wrappers
|
||||
|
||||
func pauseJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "pause", job.id], success: "Paused")
|
||||
}
|
||||
|
||||
func resumeJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "resume", job.id], success: "Resumed")
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "remove", job.id], success: "Removed")
|
||||
if selectedJob?.id == job.id {
|
||||
selectedJob = nil
|
||||
jobOutput = nil
|
||||
}
|
||||
}
|
||||
|
||||
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
|
||||
var args = ["cron", "create"]
|
||||
if !name.isEmpty { args += ["--name", name] }
|
||||
if !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
if !script.isEmpty { args += ["--script", script] }
|
||||
args.append(schedule)
|
||||
if !prompt.isEmpty { args.append(prompt) }
|
||||
runAndReload(args, success: "Job created")
|
||||
}
|
||||
|
||||
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
|
||||
var args = ["cron", "edit", id]
|
||||
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
|
||||
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||
if let name, !name.isEmpty { args += ["--name", name] }
|
||||
if let deliver { args += ["--deliver", deliver] }
|
||||
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
|
||||
if clearSkills {
|
||||
args.append("--clear-skills")
|
||||
} else if let newSkills {
|
||||
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
|
||||
}
|
||||
if let script { args += ["--script", script] }
|
||||
runAndReload(args, success: "Updated")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func runAndReload(_ arguments: [String], success: String) {
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.message = success
|
||||
} else {
|
||||
self.message = "Failed: \(result.output.prefix(200))"
|
||||
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,55 +2,141 @@ import SwiftUI
|
||||
|
||||
struct CronView: View {
|
||||
@State private var viewModel = CronViewModel()
|
||||
@State private var pendingDelete: HermesCronJob?
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
jobsList
|
||||
.frame(minWidth: 300, idealWidth: 350)
|
||||
.frame(minWidth: 320, idealWidth: 360)
|
||||
jobDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.createJob(
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
skills: form.skills,
|
||||
script: form.script,
|
||||
repeatCount: form.repeatCount
|
||||
)
|
||||
viewModel.showCreateSheet = false
|
||||
} onCancel: {
|
||||
viewModel.showCreateSheet = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewModel.editingJob) { job in
|
||||
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
|
||||
viewModel.updateJob(
|
||||
id: job.id,
|
||||
schedule: form.schedule,
|
||||
prompt: form.prompt,
|
||||
name: form.name,
|
||||
deliver: form.deliver,
|
||||
repeatCount: form.repeatCount,
|
||||
newSkills: form.skills,
|
||||
clearSkills: form.clearSkills,
|
||||
script: form.script
|
||||
)
|
||||
viewModel.editingJob = nil
|
||||
} onCancel: {
|
||||
viewModel.editingJob = nil
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete.map { "Delete \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let job = pendingDelete { viewModel.deleteJob(job) }
|
||||
pendingDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
Text("This removes the scheduled job permanently.")
|
||||
}
|
||||
}
|
||||
|
||||
private var jobsList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "info.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.showCreateSheet = true
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
Divider()
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedJob?.id },
|
||||
set: { id in
|
||||
if let id, let job = viewModel.jobs.first(where: { $0.id == id }) {
|
||||
viewModel.selectJob(job)
|
||||
} else {
|
||||
viewModel.selectedJob = nil
|
||||
viewModel.jobOutput = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
HStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(job.enabled ? .primary : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.lineLimit(1)
|
||||
Text(job.schedule.display ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
.contextMenu {
|
||||
Button(job.enabled ? "Pause" : "Resume") {
|
||||
if job.enabled {
|
||||
viewModel.pauseJob(job)
|
||||
} else {
|
||||
viewModel.resumeJob(job)
|
||||
}
|
||||
}
|
||||
Button("Run Now") { viewModel.runNow(job) }
|
||||
Button("Edit") { viewModel.editingJob = job }
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) { pendingDelete = job }
|
||||
}
|
||||
}
|
||||
.tag(job.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if viewModel.jobs.isEmpty {
|
||||
ContentUnavailableView("No Cron Jobs", systemImage: "clock.arrow.2.circlepath", description: Text("No scheduled jobs configured"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,79 +146,10 @@ struct CronView: View {
|
||||
if let job = viewModel.selectedJob {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliver {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
detailHeader(job)
|
||||
actionBar(job)
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
detailBody(job)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
@@ -142,4 +159,257 @@ struct CronView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(job.name)
|
||||
.font(.title2.bold())
|
||||
HStack(spacing: 16) {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionBar(_ job: HermesCronJob) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
viewModel.runNow(job)
|
||||
} label: {
|
||||
Label("Run Now", systemImage: "bolt")
|
||||
}
|
||||
Button {
|
||||
viewModel.editingJob = job
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
pendingDelete = job
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailBody(_ job: HermesCronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(job.prompt)
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if let script = job.preRunScript, !script.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Pre-Run Script")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(script)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
ForEach(skills, id: \.self) { skill in
|
||||
Text(skill)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nextRun = job.nextRunAt {
|
||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let lastRun = job.lastRunAt {
|
||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = job.lastError {
|
||||
Label(error, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let timeout = job.timeoutSeconds {
|
||||
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let failures = job.deliveryFailures, failures > 0 {
|
||||
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let deliveryError = job.lastDeliveryError {
|
||||
Label(deliveryError, systemImage: "paperplane.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if let output = viewModel.jobOutput {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
|
||||
struct CronJobEditor: View {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(HermesCronJob)
|
||||
}
|
||||
|
||||
struct FormState {
|
||||
var name: String = ""
|
||||
var schedule: String = ""
|
||||
var prompt: String = ""
|
||||
var deliver: String = ""
|
||||
var repeatCount: String = ""
|
||||
var skills: [String] = []
|
||||
var clearSkills: Bool = false
|
||||
var script: String = ""
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let availableSkills: [String]
|
||||
let onSave: (FormState) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var form = FormState()
|
||||
@State private var isEditMode = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(headerText)
|
||||
.font(.headline)
|
||||
formField("Name", text: $form.name, placeholder: "Friendly label")
|
||||
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Prompt")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $form.prompt)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 100)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
|
||||
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
|
||||
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
|
||||
if !availableSkills.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(availableSkills, id: \.self) { skill in
|
||||
Toggle(skill, isOn: Binding(
|
||||
get: { form.skills.contains(skill) },
|
||||
set: { on in
|
||||
if on {
|
||||
form.skills.append(skill)
|
||||
} else {
|
||||
form.skills.removeAll { $0 == skill }
|
||||
}
|
||||
}
|
||||
))
|
||||
.font(.caption.monospaced())
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
if isEditMode {
|
||||
Toggle("Clear all skills on save", isOn: $form.clearSkills)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
Button("Save") { onSave(form) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(form.schedule.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 560, minHeight: 560)
|
||||
.onAppear {
|
||||
if case .edit(let job) = mode {
|
||||
isEditMode = true
|
||||
form.name = job.name
|
||||
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
|
||||
form.prompt = job.prompt
|
||||
form.deliver = job.deliver ?? ""
|
||||
form.skills = job.skills ?? []
|
||||
form.script = job.preRunScript ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerText: String {
|
||||
switch mode {
|
||||
case .create: return "Create Cron Job"
|
||||
case .edit(let job): return "Edit \(job.name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(mono ? .system(.caption, design: .monospaced) : .caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ final class DashboardViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var stats = HermesDataService.SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
||||
)
|
||||
var stats = HermesDataService.SessionStats.empty
|
||||
var recentSessions: [HermesSession] = []
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var config = HermesConfig.empty
|
||||
|
||||
@@ -60,6 +60,10 @@ struct DashboardView: View {
|
||||
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
||||
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||
if cost > 0 {
|
||||
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,14 +94,6 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusCard: View {
|
||||
@@ -168,6 +164,9 @@ struct SessionRow: View {
|
||||
HStack(spacing: 12) {
|
||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||
if let cost = session.displayCostUSD, cost > 0 {
|
||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -19,17 +19,7 @@ struct PlatformInfo: Identifiable {
|
||||
|
||||
var isConnected: Bool { state == "connected" }
|
||||
|
||||
var icon: String {
|
||||
switch name {
|
||||
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"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
var icon: String { KnownPlatforms.icon(for: name) }
|
||||
}
|
||||
|
||||
struct PairedUser: Identifiable {
|
||||
|
||||
@@ -177,15 +177,7 @@ struct GatewayView: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ platform: String) -> String {
|
||||
switch platform {
|
||||
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"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
KnownPlatforms.icon(for: platform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ struct HealthSection: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class HealthViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var version = ""
|
||||
var updateInfo = ""
|
||||
var hasUpdate = false
|
||||
@@ -31,9 +33,17 @@ final class HealthViewModel {
|
||||
var warningCount = 0
|
||||
var okCount = 0
|
||||
var isLoading = false
|
||||
var hermesRunning = false
|
||||
var hermesPID: pid_t?
|
||||
var actionMessage: String?
|
||||
|
||||
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
|
||||
var diagnosticsOutput: String = ""
|
||||
var isSharingDebug = false
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
refreshProcessStatus()
|
||||
loadVersion()
|
||||
let statusOutput = runHermes(["status"]).output
|
||||
statusSections = parseOutput(statusOutput)
|
||||
@@ -43,6 +53,41 @@ final class HealthViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refreshProcessStatus() {
|
||||
hermesPID = fileService.hermesPID()
|
||||
hermesRunning = hermesPID != nil
|
||||
}
|
||||
|
||||
func stopHermes() {
|
||||
fileService.stopHermes()
|
||||
actionMessage = "Stop signal sent"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func startHermes() {
|
||||
runHermes(["gateway", "start"])
|
||||
actionMessage = "Start requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func restartHermes() {
|
||||
fileService.stopHermes()
|
||||
actionMessage = "Restarting..."
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.runHermes(["gateway", "start"])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVersion() {
|
||||
let output = runHermes(["version"]).output
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
@@ -160,6 +205,37 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture `hermes dump` output — a setup summary used for debugging / support.
|
||||
/// Does NOT upload anything.
|
||||
func runDump() {
|
||||
actionMessage = "Running dump…"
|
||||
let result = runHermes(["dump"])
|
||||
diagnosticsOutput = result.output
|
||||
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
|
||||
/// Research support infrastructure — caller must confirm with the user first.
|
||||
func runDebugShare() {
|
||||
isSharingDebug = true
|
||||
actionMessage = "Uploading debug report…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
|
||||
await MainActor.run {
|
||||
self.isSharingDebug = false
|
||||
self.diagnosticsOutput = result.output
|
||||
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
|
||||
@@ -4,18 +4,38 @@ struct HealthView: View {
|
||||
@State private var viewModel = HealthViewModel()
|
||||
@State private var expandedSection: UUID?
|
||||
@State private var selectedTab = 0
|
||||
@State private var showShareConfirm = false
|
||||
@State private var showDiagnostics = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerBar
|
||||
Divider()
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
HStack {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Status").tag(0)
|
||||
Text("Diagnostics").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
Spacer()
|
||||
Button("Run Dump") {
|
||||
viewModel.runDump()
|
||||
showDiagnostics = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Share Debug Report…") {
|
||||
showShareConfirm = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.isSharingDebug)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal)
|
||||
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
|
||||
Divider()
|
||||
diagnosticsPanel
|
||||
}
|
||||
Divider()
|
||||
ScrollView {
|
||||
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
|
||||
@@ -24,41 +44,114 @@ struct HealthView: View {
|
||||
}
|
||||
.navigationTitle("Health")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||
Button("Upload", role: .destructive) {
|
||||
viewModel.runDebugShare()
|
||||
showDiagnostics = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Diagnostic Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Hide") { showDiagnostics = false }
|
||||
.controlSize(.mini)
|
||||
}
|
||||
ScrollView {
|
||||
Text(viewModel.diagnosticsOutput)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 16) {
|
||||
if !viewModel.version.isEmpty {
|
||||
Text(viewModel.version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.hasUpdate {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
Text(viewModel.updateInfo)
|
||||
.font(.caption)
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 16) {
|
||||
if !viewModel.version.isEmpty {
|
||||
Text(viewModel.version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
if viewModel.hasUpdate {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
Text(viewModel.updateInfo)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
||||
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
||||
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
||||
}
|
||||
|
||||
Button("Refresh") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Spacer()
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
||||
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
||||
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(viewModel.hermesRunning ? .green : .red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
|
||||
.font(.caption.bold())
|
||||
if let pid = viewModel.hermesPID {
|
||||
Text("PID \(pid)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Refresh") { viewModel.load() }
|
||||
if let msg = viewModel.actionMessage {
|
||||
Label(msg, systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Start") { viewModel.startHermes() }
|
||||
.disabled(viewModel.hermesRunning)
|
||||
Button("Stop") { viewModel.stopHermes() }
|
||||
.disabled(!viewModel.hermesRunning)
|
||||
Button("Restart") { viewModel.restartHermes() }
|
||||
.disabled(!viewModel.hermesRunning)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Grid
|
||||
|
||||
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
||||
let reasoningTokens: Int
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||
}
|
||||
|
||||
struct PlatformUsage: Identifiable {
|
||||
@@ -46,7 +47,7 @@ struct ToolUsage: Identifiable {
|
||||
}
|
||||
|
||||
struct NotableSession: Identifiable {
|
||||
var id: String { session.id }
|
||||
var id: String { "\(session.id)-\(label)" }
|
||||
let label: String
|
||||
let value: String
|
||||
let session: HermesSession
|
||||
@@ -69,7 +70,9 @@ final class InsightsViewModel {
|
||||
var totalOutputTokens = 0
|
||||
var totalCacheReadTokens = 0
|
||||
var totalCacheWriteTokens = 0
|
||||
var totalReasoningTokens = 0
|
||||
var totalTokens = 0
|
||||
var totalCost: Double = 0
|
||||
var activeTime: TimeInterval = 0
|
||||
var avgSessionDuration: TimeInterval = 0
|
||||
|
||||
@@ -119,7 +122,9 @@ final class InsightsViewModel {
|
||||
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
||||
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||
|
||||
var total: TimeInterval = 0
|
||||
var count = 0
|
||||
@@ -134,21 +139,22 @@ final class InsightsViewModel {
|
||||
}
|
||||
|
||||
private func computeModelBreakdown() {
|
||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
|
||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||
for s in sessions {
|
||||
let model = s.model ?? "unknown"
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.input += s.inputTokens
|
||||
entry.output += s.outputTokens
|
||||
entry.cacheRead += s.cacheReadTokens
|
||||
entry.cacheWrite += s.cacheWriteTokens
|
||||
entry.reasoning += s.reasoningTokens
|
||||
grouped[model] = entry
|
||||
}
|
||||
modelUsage = grouped.map { key, val in
|
||||
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||
cacheWriteTokens: val.cacheWrite)
|
||||
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||
}
|
||||
|
||||
@@ -158,7 +164,7 @@ final class InsightsViewModel {
|
||||
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.messages += s.messageCount
|
||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
|
||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||
grouped[s.source] = entry
|
||||
}
|
||||
platformUsage = grouped.map { key, val in
|
||||
|
||||
@@ -50,7 +50,9 @@ struct InsightsView: View {
|
||||
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||
@@ -273,19 +275,12 @@ struct InsightsView: View {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func platformIcon(_ 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 "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
KnownPlatforms.icon(for: platform)
|
||||
}
|
||||
|
||||
private func barColor(for toolName: String) -> Color {
|
||||
switch toolName {
|
||||
case "terminal": return .orange
|
||||
case "terminal", "execute_code": return .orange
|
||||
case "read_file", "search_files": return .green
|
||||
case "write_file", "patch": return .blue
|
||||
case "web_search", "web_extract": return .purple
|
||||
|
||||
@@ -5,12 +5,14 @@ final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .errors
|
||||
var selectedLogFile: LogFile = .agent
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var selectedComponent: LogComponent = .all
|
||||
var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
@@ -18,17 +20,44 @@ final class LogsViewModel {
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .agent: return HermesPaths.agentLog
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LogComponent: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case gateway = "Gateway"
|
||||
case agent = "Agent"
|
||||
case tools = "Tools"
|
||||
case cli = "CLI"
|
||||
case cron = "Cron"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var loggerPrefix: String? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .gateway: return "gateway"
|
||||
case .agent: return "agent"
|
||||
case .tools: return "tools"
|
||||
case .cli: return "cli"
|
||||
case .cron: return "cron"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filteredEntries: [LogEntry] {
|
||||
entries.filter { entry in
|
||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||
return levelOk && searchOk
|
||||
let componentOk: Bool = {
|
||||
guard let prefix = selectedComponent.loggerPrefix else { return true }
|
||||
return entry.logger.hasPrefix(prefix)
|
||||
}()
|
||||
return levelOk && searchOk && componentOk
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ struct LogsView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
|
||||
Picker("Component", selection: $viewModel.selectedComponent) {
|
||||
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
||||
Text(component.rawValue).tag(component)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 140)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("Level", selection: $viewModel.filterLevel) {
|
||||
@@ -58,6 +65,27 @@ struct LogsView: View {
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(colorForLevel(entry.level))
|
||||
.frame(width: 60, alignment: .leading)
|
||||
if let sessionId = entry.sessionId {
|
||||
Button {
|
||||
viewModel.searchText = sessionId
|
||||
} label: {
|
||||
Text(sessionId)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Filter to session \(sessionId)")
|
||||
}
|
||||
Text(entry.logger)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 140, alignment: .leading)
|
||||
Text(entry.message)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class MCPServerEditorViewModel {
|
||||
struct KeyValueRow: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var value: String
|
||||
}
|
||||
|
||||
private let fileService = HermesFileService()
|
||||
let server: HermesMCPServer
|
||||
|
||||
var envDraft: [KeyValueRow]
|
||||
var headersDraft: [KeyValueRow]
|
||||
var includeDraft: String
|
||||
var excludeDraft: String
|
||||
var resourcesEnabled: Bool
|
||||
var promptsEnabled: Bool
|
||||
var timeoutDraft: String
|
||||
var connectTimeoutDraft: String
|
||||
var showSecrets: Bool = false
|
||||
var isSaving: Bool = false
|
||||
var saveError: String?
|
||||
|
||||
init(server: HermesMCPServer) {
|
||||
self.server = server
|
||||
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
|
||||
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
||||
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
||||
self.excludeDraft = server.toolsExclude.joined(separator: ", ")
|
||||
self.resourcesEnabled = server.resourcesEnabled
|
||||
self.promptsEnabled = server.promptsEnabled
|
||||
self.timeoutDraft = server.timeout.map { String($0) } ?? ""
|
||||
self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? ""
|
||||
}
|
||||
|
||||
func appendEnvRow() {
|
||||
envDraft.append(KeyValueRow(key: "", value: ""))
|
||||
}
|
||||
|
||||
func removeEnvRow(id: UUID) {
|
||||
envDraft.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func appendHeaderRow() {
|
||||
headersDraft.append(KeyValueRow(key: "", value: ""))
|
||||
}
|
||||
|
||||
func removeHeaderRow(id: UUID) {
|
||||
headersDraft.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func save(completion: @escaping (Bool) -> Void) {
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
|
||||
let envMap = Dictionary(uniqueKeysWithValues: envDraft
|
||||
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||
let headerMap = Dictionary(uniqueKeysWithValues: headersDraft
|
||||
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||
let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||
let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||
|
||||
let service = fileService
|
||||
let transport = server.transport
|
||||
let name = server.name
|
||||
let resources = resourcesEnabled
|
||||
let prompts = promptsEnabled
|
||||
|
||||
Task.detached {
|
||||
var success = true
|
||||
switch transport {
|
||||
case .stdio:
|
||||
if !service.setMCPServerEnv(name: name, env: envMap) { success = false }
|
||||
case .http:
|
||||
if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false }
|
||||
}
|
||||
if !service.updateMCPToolFilters(
|
||||
name: name,
|
||||
include: include,
|
||||
exclude: exclude,
|
||||
resources: resources,
|
||||
prompts: prompts
|
||||
) { success = false }
|
||||
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
||||
success = false
|
||||
}
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
if !success {
|
||||
self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)."
|
||||
}
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearOAuthToken(completion: @escaping (Bool) -> Void) {
|
||||
let service = fileService
|
||||
let name = server.name
|
||||
Task.detached {
|
||||
let ok = service.deleteMCPOAuthToken(name: name)
|
||||
await MainActor.run { completion(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class MCPServersViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var servers: [HermesMCPServer] = []
|
||||
var selectedServerName: String?
|
||||
var searchText = ""
|
||||
var isLoading = false
|
||||
var statusMessage: String?
|
||||
var showPresetPicker = false
|
||||
var showAddCustom = false
|
||||
var showRestartBanner = false
|
||||
var testResults: [String: MCPTestResult] = [:]
|
||||
var testingNames: Set<String> = []
|
||||
var activeError: String?
|
||||
var editingServer: HermesMCPServer?
|
||||
|
||||
var filteredServers: [HermesMCPServer] {
|
||||
guard !searchText.isEmpty else { return servers }
|
||||
let query = searchText.lowercased()
|
||||
return servers.filter { server in
|
||||
server.name.lowercased().contains(query) ||
|
||||
server.summary.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var stdioServers: [HermesMCPServer] {
|
||||
filteredServers.filter { $0.transport == .stdio }
|
||||
}
|
||||
|
||||
var httpServers: [HermesMCPServer] {
|
||||
filteredServers.filter { $0.transport == .http }
|
||||
}
|
||||
|
||||
var selectedServer: HermesMCPServer? {
|
||||
guard let name = selectedServerName else { return nil }
|
||||
return servers.first(where: { $0.name == name })
|
||||
}
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
servers = fileService.loadMCPServers()
|
||||
isLoading = false
|
||||
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
|
||||
selectedServerName = nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectServer(name: String?) {
|
||||
selectedServerName = name
|
||||
}
|
||||
|
||||
func beginEdit() {
|
||||
editingServer = selectedServer
|
||||
}
|
||||
|
||||
func finishEdit(reload: Bool) {
|
||||
editingServer = nil
|
||||
if reload {
|
||||
load()
|
||||
showRestartBanner = true
|
||||
}
|
||||
}
|
||||
|
||||
func deleteServer(name: String) {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = fileService.removeMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Removed \(name)")
|
||||
if self.selectedServerName == name {
|
||||
self.selectedServerName = nil
|
||||
}
|
||||
self.testResults.removeValue(forKey: name)
|
||||
self.load()
|
||||
self.showRestartBanner = true
|
||||
} else {
|
||||
self.activeError = "Remove failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleEnabled(name: String) {
|
||||
guard let server = servers.first(where: { $0.name == name }) else { return }
|
||||
let newValue = !server.enabled
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let ok = fileService.toggleMCPServerEnabled(name: name, enabled: newValue)
|
||||
await MainActor.run {
|
||||
if ok {
|
||||
self.flashStatus(newValue ? "Enabled \(name)" : "Disabled \(name)")
|
||||
self.load()
|
||||
self.showRestartBanner = true
|
||||
} else {
|
||||
self.activeError = "Could not update \(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testServer(name: String) {
|
||||
guard !testingNames.contains(name) else { return }
|
||||
testingNames.insert(name)
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = await fileService.testMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
self.testingNames.remove(name)
|
||||
self.testResults[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAll() {
|
||||
let targets = servers.map(\.name)
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
for name in targets {
|
||||
let result = await fileService.testMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
self.testResults[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFromPreset(preset: MCPServerPreset, name: String, pathArg: String?, envValues: [String: String]) {
|
||||
let fileService = self.fileService
|
||||
let allArgs: [String] = {
|
||||
var base = preset.args
|
||||
if let pathArg, !pathArg.isEmpty { base.append(pathArg) }
|
||||
return base
|
||||
}()
|
||||
Task.detached {
|
||||
let addResult: (exitCode: Int32, output: String)
|
||||
switch preset.transport {
|
||||
case .stdio:
|
||||
addResult = fileService.addMCPServerStdio(
|
||||
name: name,
|
||||
command: preset.command ?? "",
|
||||
args: allArgs
|
||||
)
|
||||
case .http:
|
||||
addResult = fileService.addMCPServerHTTP(
|
||||
name: name,
|
||||
url: preset.url ?? "",
|
||||
auth: preset.auth
|
||||
)
|
||||
}
|
||||
guard addResult.exitCode == 0 else {
|
||||
await MainActor.run {
|
||||
self.activeError = "Add failed: \(addResult.output)"
|
||||
}
|
||||
return
|
||||
}
|
||||
if !envValues.isEmpty {
|
||||
_ = fileService.setMCPServerEnv(name: name, env: envValues)
|
||||
}
|
||||
await MainActor.run {
|
||||
self.flashStatus("Added \(name)")
|
||||
self.load()
|
||||
self.selectedServerName = name
|
||||
self.showRestartBanner = true
|
||||
self.showPresetPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCustom(name: String, transport: MCPTransport, command: String, args: [String], url: String, auth: String?) {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result: (exitCode: Int32, output: String)
|
||||
switch transport {
|
||||
case .stdio:
|
||||
result = fileService.addMCPServerStdio(name: name, command: command, args: args)
|
||||
case .http:
|
||||
result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth)
|
||||
}
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Added \(name)")
|
||||
self.load()
|
||||
self.selectedServerName = name
|
||||
self.showRestartBanner = true
|
||||
self.showAddCustom = false
|
||||
} else {
|
||||
self.activeError = "Add failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restartGateway() {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = fileService.restartGateway()
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Gateway restarted")
|
||||
self.showRestartBanner = false
|
||||
} else {
|
||||
self.activeError = "Restart failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flashStatus(_ message: String) {
|
||||
statusMessage = message
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
await MainActor.run {
|
||||
if self.statusMessage == message {
|
||||
self.statusMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerAddCustomView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name: String = ""
|
||||
@State private var transport: MCPTransport = .stdio
|
||||
@State private var command: String = "npx"
|
||||
@State private var argsText: String = ""
|
||||
@State private var url: String = ""
|
||||
@State private var auth: String = "none"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Add Custom MCP Server")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Add") {
|
||||
submit()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionBox(title: "Identity") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Name").font(.caption.bold())
|
||||
TextField("my_server", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Becomes the key under mcp_servers: in config.yaml.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
sectionBox(title: "Transport") {
|
||||
Picker("", selection: $transport) {
|
||||
ForEach(MCPTransport.allCases) { t in
|
||||
Text(t.displayName).tag(t)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
if transport == .stdio {
|
||||
stdioSection
|
||||
} else {
|
||||
httpSection
|
||||
}
|
||||
Text("Env vars, headers, and tool filters can be edited after the server is added.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 500)
|
||||
}
|
||||
|
||||
private var stdioSection: some View {
|
||||
sectionBox(title: "Command") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Command").font(.caption.bold())
|
||||
TextField("npx", text: $command)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Args (one per line)").font(.caption.bold())
|
||||
TextEditor(text: $argsText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 80)
|
||||
.padding(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var httpSection: some View {
|
||||
sectionBox(title: "Endpoint") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("URL").font(.caption.bold())
|
||||
TextField("https://...", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Auth").font(.caption.bold())
|
||||
Picker("", selection: $auth) {
|
||||
Text("None").tag("none")
|
||||
Text("OAuth 2.1").tag("oauth")
|
||||
Text("Header").tag("header")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return false }
|
||||
switch transport {
|
||||
case .stdio:
|
||||
return !command.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .http:
|
||||
return !url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let args = argsText
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
let resolvedAuth: String? = (auth == "none") ? nil : auth
|
||||
viewModel.addCustom(
|
||||
name: trimmedName,
|
||||
transport: transport,
|
||||
command: command.trimmingCharacters(in: .whitespaces),
|
||||
args: args,
|
||||
url: url.trimmingCharacters(in: .whitespaces),
|
||||
auth: resolvedAuth
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
content()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerDetailView: View {
|
||||
let server: HermesMCPServer
|
||||
let testResult: MCPTestResult?
|
||||
let isTesting: Bool
|
||||
let onTest: () -> Void
|
||||
let onToggleEnabled: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
overview
|
||||
if server.transport == .stdio {
|
||||
envSection
|
||||
} else {
|
||||
headersSection
|
||||
}
|
||||
toolsSection
|
||||
timeoutsSection
|
||||
if let result = testResult {
|
||||
MCPServerTestResultView(result: result)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Remove \(server.name)?",
|
||||
isPresented: $showDeleteConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Remove", role: .destructive) { onDelete() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This removes the server from config.yaml and deletes any OAuth token.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(server.name)
|
||||
.font(.title2.bold())
|
||||
if !server.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if server.hasOAuthToken {
|
||||
Label("OAuth", systemImage: "key.fill")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(server.transport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
onTest()
|
||||
} label: {
|
||||
if isTesting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Test", systemImage: "bolt.horizontal")
|
||||
}
|
||||
}
|
||||
.disabled(isTesting)
|
||||
Button {
|
||||
onToggleEnabled()
|
||||
} label: {
|
||||
Label(server.enabled ? "Disable" : "Enable", systemImage: server.enabled ? "pause.circle" : "play.circle")
|
||||
}
|
||||
Button {
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overview: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Connection")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
switch server.transport {
|
||||
case .stdio:
|
||||
summaryRow(label: "Command", value: server.command ?? "—")
|
||||
if !server.args.isEmpty {
|
||||
summaryRow(label: "Args", value: server.args.joined(separator: " "))
|
||||
}
|
||||
case .http:
|
||||
summaryRow(label: "URL", value: server.url ?? "—")
|
||||
if let auth = server.auth, !auth.isEmpty {
|
||||
summaryRow(label: "Auth", value: auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func summaryRow(label: String, value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var envSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Environment Variables")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if server.env.isEmpty {
|
||||
Text("No env vars configured.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(server.env.keys.sorted(), id: \.self) { key in
|
||||
HStack {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var headersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Headers")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if server.headers.isEmpty {
|
||||
Text("No headers configured.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(server.headers.keys.sorted(), id: \.self) { key in
|
||||
HStack {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var toolsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Tool Filters")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
summaryRow(label: "Include", value: server.toolsInclude.isEmpty ? "(all)" : server.toolsInclude.joined(separator: ", "))
|
||||
summaryRow(label: "Exclude", value: server.toolsExclude.isEmpty ? "—" : server.toolsExclude.joined(separator: ", "))
|
||||
summaryRow(label: "Resources", value: server.resourcesEnabled ? "enabled" : "disabled")
|
||||
summaryRow(label: "Prompts", value: server.promptsEnabled ? "enabled" : "disabled")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var timeoutsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Timeouts")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
summaryRow(label: "Connect", value: server.connectTimeout.map { "\($0)s" } ?? "default")
|
||||
summaryRow(label: "Call", value: server.timeout.map { "\($0)s" } ?? "default")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerEditorView: View {
|
||||
@State var viewModel: MCPServerEditorViewModel
|
||||
let onSave: (Bool) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Edit \(viewModel.server.name)")
|
||||
.font(.headline)
|
||||
Text(viewModel.server.transport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button {
|
||||
viewModel.save { changed in
|
||||
if changed { onSave(true) }
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(viewModel.isSaving)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let error = viewModel.saveError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.red.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if viewModel.server.transport == .stdio {
|
||||
envSection
|
||||
} else {
|
||||
headersSection
|
||||
}
|
||||
toolsSection
|
||||
timeoutsSection
|
||||
if viewModel.server.hasOAuthToken {
|
||||
oauthSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 560)
|
||||
}
|
||||
|
||||
private var envSection: some View {
|
||||
sectionBox(title: "Environment Variables") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.envDraft.isEmpty {
|
||||
Text("No env vars. Add one with the button below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach($viewModel.envDraft) { $row in
|
||||
HStack(spacing: 8) {
|
||||
TextField("KEY", text: $row.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(maxWidth: 240)
|
||||
if viewModel.showSecrets {
|
||||
TextField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
SecureField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeEnvRow(id: row.id)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.appendEnvRow()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
}
|
||||
Spacer()
|
||||
Toggle("Show values", isOn: $viewModel.showSecrets)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headersSection: some View {
|
||||
sectionBox(title: "Headers") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.headersDraft.isEmpty {
|
||||
Text("No headers. Add one with the button below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach($viewModel.headersDraft) { $row in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Header", text: $row.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
TextField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeHeaderRow(id: row.id)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
viewModel.appendHeaderRow()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolsSection: some View {
|
||||
sectionBox(title: "Tool Filters") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Include (comma-separated — if set, only these are exposed)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("tool_a, tool_b", text: $viewModel.includeDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Exclude")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("tool_c", text: $viewModel.excludeDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
Toggle("Expose resources", isOn: $viewModel.resourcesEnabled)
|
||||
Toggle("Expose prompts", isOn: $viewModel.promptsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timeoutsSection: some View {
|
||||
sectionBox(title: "Timeouts (seconds)") {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connect timeout")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("default", text: $viewModel.connectTimeoutDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 140)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Call timeout")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("default", text: $viewModel.timeoutDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 140)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var oauthSection: some View {
|
||||
sectionBox(title: "OAuth Token") {
|
||||
HStack {
|
||||
Text("Token on disk. Clear to re-authenticate next time the gateway connects.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Clear Token", role: .destructive) {
|
||||
viewModel.clearOAuthToken { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
content()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerPresetPickerView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedPreset: MCPServerPreset?
|
||||
@State private var nameOverride: String = ""
|
||||
@State private var pathArg: String = ""
|
||||
@State private var envValues: [String: String] = [:]
|
||||
@State private var showSecrets: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if let preset = selectedPreset {
|
||||
configureStep(preset: preset)
|
||||
} else {
|
||||
galleryStep
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 560)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if selectedPreset != nil {
|
||||
Button {
|
||||
selectedPreset = nil
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(selectedPreset?.displayName ?? "Add from Preset")
|
||||
.font(.headline)
|
||||
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var galleryStep: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(MCPServerPreset.categories, id: \.self) { category in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(category)
|
||||
.font(.subheadline.bold())
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 200), spacing: 12)],
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(MCPServerPreset.byCategory(category)) { preset in
|
||||
presetCard(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func presetCard(_ preset: MCPServerPreset) -> some View {
|
||||
Button {
|
||||
selectedPreset = preset
|
||||
nameOverride = preset.id
|
||||
pathArg = ""
|
||||
envValues = Dictionary(uniqueKeysWithValues: preset.requiredEnvKeys.map { ($0, "") })
|
||||
for key in preset.optionalEnvKeys {
|
||||
envValues[key] = ""
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Image(systemName: preset.iconSystemName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(preset.displayName)
|
||||
.font(.body.bold())
|
||||
Spacer()
|
||||
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(preset.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if !preset.requiredEnvKeys.isEmpty {
|
||||
Text("Requires: \(preset.requiredEnvKeys.joined(separator: ", "))")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func configureStep(preset: MCPServerPreset) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
nameField
|
||||
if let prompt = preset.pathArgPrompt {
|
||||
pathArgField(prompt: prompt)
|
||||
}
|
||||
if !preset.requiredEnvKeys.isEmpty || !preset.optionalEnvKeys.isEmpty {
|
||||
envFields(preset: preset)
|
||||
}
|
||||
if !preset.docsURL.isEmpty {
|
||||
Link(destination: URL(string: preset.docsURL) ?? URL(string: "https://modelcontextprotocol.io")!) {
|
||||
Label("Docs", systemImage: "book")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add Server") {
|
||||
submit(preset: preset)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit(preset: preset))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var nameField: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Server name")
|
||||
.font(.caption.bold())
|
||||
TextField("e.g. github", text: $nameOverride)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Used as the YAML key. Lowercase, no spaces.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func pathArgField(prompt: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(prompt)
|
||||
.font(.caption.bold())
|
||||
TextField(prompt, text: $pathArg)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
private func envFields(preset: MCPServerPreset) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Environment Variables")
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
Toggle("Show values", isOn: $showSecrets)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
ForEach(preset.requiredEnvKeys, id: \.self) { key in
|
||||
envRow(key: key, required: true)
|
||||
}
|
||||
ForEach(preset.optionalEnvKeys, id: \.self) { key in
|
||||
envRow(key: key, required: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func envRow(key: String, required: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
if required {
|
||||
Text("required")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.frame(width: 240, alignment: .leading)
|
||||
if showSecrets {
|
||||
TextField("value", text: bindingForEnv(key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
SecureField("value", text: bindingForEnv(key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForEnv(_ key: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: { envValues[key] ?? "" },
|
||||
set: { envValues[key] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func canSubmit(preset: MCPServerPreset) -> Bool {
|
||||
let trimmedName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return false }
|
||||
if preset.pathArgPrompt != nil && pathArg.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return false
|
||||
}
|
||||
for key in preset.requiredEnvKeys {
|
||||
if (envValues[key] ?? "").trimmingCharacters(in: .whitespaces).isEmpty { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func submit(preset: MCPServerPreset) {
|
||||
let finalName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||
let finalPath = pathArg.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedEnv = envValues.reduce(into: [String: String]()) { acc, pair in
|
||||
let trimmedValue = pair.value.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedValue.isEmpty { acc[pair.key] = pair.value }
|
||||
}
|
||||
viewModel.addFromPreset(
|
||||
preset: preset,
|
||||
name: finalName,
|
||||
pathArg: preset.pathArgPrompt != nil ? finalPath : nil,
|
||||
envValues: trimmedEnv
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerTestResultView: View {
|
||||
let result: MCPTestResult
|
||||
@State private var showOutput = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
||||
.foregroundStyle(result.succeeded ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.succeeded ? "Test passed" : "Test failed")
|
||||
.font(.subheadline.bold())
|
||||
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showOutput.toggle()
|
||||
} label: {
|
||||
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
if !result.tools.isEmpty {
|
||||
WrapChips(items: result.tools)
|
||||
}
|
||||
if showOutput {
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? "(no output)" : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
.background(Color.black.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background((result.succeeded ? Color.green : Color.red).opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WrapChips: View {
|
||||
let items: [String]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 6)], spacing: 6) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
Text(item)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServersView: View {
|
||||
@State private var viewModel = MCPServersViewModel()
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
serversList
|
||||
.frame(minWidth: 260, idealWidth: 300)
|
||||
serverDetail
|
||||
.frame(minWidth: 500)
|
||||
}
|
||||
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
viewModel.showPresetPicker = true
|
||||
} label: {
|
||||
Label("Add from Preset", systemImage: "square.grid.2x2")
|
||||
}
|
||||
Button {
|
||||
viewModel.showAddCustom = true
|
||||
} label: {
|
||||
Label("Add Custom", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
viewModel.testAll()
|
||||
} label: {
|
||||
Label("Test All", systemImage: "bolt.horizontal")
|
||||
}
|
||||
.disabled(viewModel.servers.isEmpty)
|
||||
Button {
|
||||
viewModel.load()
|
||||
} label: {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showPresetPicker) {
|
||||
MCPServerPresetPickerView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showAddCustom) {
|
||||
MCPServerAddCustomView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.editingServer != nil },
|
||||
set: { if !$0 { viewModel.editingServer = nil } }
|
||||
)) {
|
||||
if let server = viewModel.editingServer {
|
||||
MCPServerEditorView(
|
||||
viewModel: MCPServerEditorViewModel(server: server),
|
||||
onSave: { changed in viewModel.finishEdit(reload: changed) },
|
||||
onCancel: { viewModel.finishEdit(reload: false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { viewModel.activeError != nil },
|
||||
set: { if !$0 { viewModel.activeError = nil } }
|
||||
)) {
|
||||
Button("OK") { viewModel.activeError = nil }
|
||||
} message: {
|
||||
Text(viewModel.activeError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var serversList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedServerName },
|
||||
set: { viewModel.selectServer(name: $0) }
|
||||
)) {
|
||||
if !viewModel.stdioServers.isEmpty {
|
||||
Section("Local (stdio)") {
|
||||
ForEach(viewModel.stdioServers) { server in
|
||||
serverRow(server)
|
||||
.tag(server.name as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !viewModel.httpServers.isEmpty {
|
||||
Section("Remote (HTTP)") {
|
||||
ForEach(viewModel.httpServers) { server in
|
||||
serverRow(server)
|
||||
.tag(server.name as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.servers.isEmpty && !viewModel.isLoading {
|
||||
Section {
|
||||
Text("No servers configured yet")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func serverRow(_ server: HermesMCPServer) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||
.foregroundStyle(server.enabled ? Color.accentColor : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.body)
|
||||
if !server.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.testingNames.contains(server.name) {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let result = viewModel.testResults[server.name] {
|
||||
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(result.succeeded ? .green : .red)
|
||||
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var serverDetail: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.showRestartBanner {
|
||||
RestartGatewayBanner(
|
||||
onRestart: { viewModel.restartGateway() },
|
||||
onDismiss: { viewModel.showRestartBanner = false }
|
||||
)
|
||||
}
|
||||
if let status = viewModel.statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.accentColor.opacity(0.12))
|
||||
}
|
||||
if let server = viewModel.selectedServer {
|
||||
MCPServerDetailView(
|
||||
server: server,
|
||||
testResult: viewModel.testResults[server.name],
|
||||
isTesting: viewModel.testingNames.contains(server.name),
|
||||
onTest: { viewModel.testServer(name: server.name) },
|
||||
onToggleEnabled: { viewModel.toggleEnabled(name: server.name) },
|
||||
onEdit: { viewModel.beginEdit() },
|
||||
onDelete: { viewModel.deleteServer(name: server.name) }
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select an MCP Server",
|
||||
systemImage: "puzzlepiece.extension",
|
||||
description: Text("Pick one from the list, or add a new server from the toolbar.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RestartGatewayBanner: View {
|
||||
let onRestart: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Gateway restart required")
|
||||
.font(.caption.bold())
|
||||
Text("Changes won't take effect until Hermes reloads the config.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Restart Now") { onRestart() }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.14))
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ final class MemoryViewModel {
|
||||
|
||||
var memoryContent = ""
|
||||
var userContent = ""
|
||||
var memoryProvider = ""
|
||||
var isEditing = false
|
||||
var editingFile: EditTarget = .memory
|
||||
var editText = ""
|
||||
var profiles: [String] = []
|
||||
var activeProfile = ""
|
||||
|
||||
enum EditTarget {
|
||||
case memory, user
|
||||
@@ -17,9 +20,30 @@ final class MemoryViewModel {
|
||||
var memoryCharCount: Int { memoryContent.count }
|
||||
var userCharCount: Int { userContent.count }
|
||||
|
||||
var hasExternalProvider: Bool {
|
||||
let stripped = memoryProvider
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))
|
||||
return !stripped.isEmpty && stripped != "file"
|
||||
}
|
||||
|
||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||
|
||||
func load() {
|
||||
memoryContent = fileService.loadMemory()
|
||||
userContent = fileService.loadUserProfile()
|
||||
let config = fileService.loadConfig()
|
||||
memoryProvider = config.memoryProvider
|
||||
profiles = fileService.loadMemoryProfiles()
|
||||
if activeProfile.isEmpty {
|
||||
activeProfile = config.memoryProfile
|
||||
}
|
||||
memoryContent = fileService.loadMemory(profile: activeProfile)
|
||||
userContent = fileService.loadUserProfile(profile: activeProfile)
|
||||
}
|
||||
|
||||
func switchProfile(_ profile: String) {
|
||||
activeProfile = profile
|
||||
memoryContent = fileService.loadMemory(profile: profile)
|
||||
userContent = fileService.loadUserProfile(profile: profile)
|
||||
}
|
||||
|
||||
func startEditing(_ target: EditTarget) {
|
||||
@@ -31,10 +55,10 @@ final class MemoryViewModel {
|
||||
func save() {
|
||||
switch editingFile {
|
||||
case .memory:
|
||||
fileService.saveMemory(editText)
|
||||
fileService.saveMemory(editText, profile: activeProfile)
|
||||
memoryContent = editText
|
||||
case .user:
|
||||
fileService.saveUserProfile(editText)
|
||||
fileService.saveUserProfile(editText, profile: activeProfile)
|
||||
userContent = editText
|
||||
}
|
||||
isEditing = false
|
||||
|
||||
@@ -7,6 +7,35 @@ struct MemoryView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if viewModel.hasMultipleProfiles {
|
||||
HStack(spacing: 8) {
|
||||
Text("Profile")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("", selection: Binding(
|
||||
get: { viewModel.activeProfile },
|
||||
set: { viewModel.switchProfile($0) }
|
||||
)) {
|
||||
Text("Default").tag("")
|
||||
ForEach(viewModel.profiles, id: \.self) { profile in
|
||||
Text(profile).tag(profile)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
}
|
||||
if viewModel.hasExternalProvider {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||
}
|
||||
@@ -42,8 +71,7 @@ struct MemoryView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
Text(markdownAttributed(content))
|
||||
.textSelection(.enabled)
|
||||
MarkdownContentView(content: content)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
@@ -64,14 +92,17 @@ struct MemoryView: View {
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
HSplitView {
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
ScrollView {
|
||||
MarkdownContentView(content: viewModel.editText)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
}
|
||||
|
||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// A personality defined under the `personalities:` block in config.yaml.
|
||||
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
|
||||
struct HermesPersonality: Identifiable, Sendable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class PersonalitiesViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var personalities: [HermesPersonality] = []
|
||||
var activeName: String = ""
|
||||
var soulMarkdown: String = ""
|
||||
var soulPath: String { HermesPaths.home + "/SOUL.md" }
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let config = fileService.loadConfig()
|
||||
activeName = config.personality
|
||||
personalities = parsePersonalitiesBlock()
|
||||
soulMarkdown = (try? String(contentsOfFile: soulPath, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
/// Parse the `personalities:` section of config.yaml using the nested parser.
|
||||
/// Each personality is a top-level key under `personalities`, optionally with
|
||||
/// a `prompt:` child.
|
||||
private func parsePersonalitiesBlock() -> [HermesPersonality] {
|
||||
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else { return [] }
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
// Find all keys "personalities.<name>[.subkey]"
|
||||
var nameSet: Set<String> = []
|
||||
for key in parsed.values.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
|
||||
}
|
||||
return nameSet.sorted().map { name in
|
||||
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
|
||||
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ name: String) {
|
||||
let result = runHermes(["config", "set", "display.personality", name])
|
||||
if result.exitCode == 0 {
|
||||
activeName = name
|
||||
message = "Active personality set to \(name)"
|
||||
} else {
|
||||
logger.warning("Failed to set personality: \(result.output)")
|
||||
message = "Failed to set personality"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveSOUL(_ content: String) {
|
||||
do {
|
||||
try content.write(toFile: soulPath, atomically: true, encoding: .utf8)
|
||||
soulMarkdown = content
|
||||
message = "SOUL.md saved"
|
||||
} catch {
|
||||
logger.error("Failed to write SOUL.md: \(error.localizedDescription)")
|
||||
message = "Save failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
process.environment = HermesFileService.enrichedEnvironment()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalitiesView: View {
|
||||
@State private var viewModel = PersonalitiesViewModel()
|
||||
@State private var soulDraft = ""
|
||||
@State private var editingSOUL = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
activeSection
|
||||
listSection
|
||||
soulSection
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Personalities")
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSection: some View {
|
||||
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
|
||||
if viewModel.personalities.isEmpty {
|
||||
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
|
||||
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
|
||||
} else {
|
||||
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var listSection: some View {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
|
||||
ForEach(viewModel.personalities) { personality in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(personality.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
if personality.name == viewModel.activeName {
|
||||
Text("active")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.background(.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !personality.prompt.isEmpty {
|
||||
Text(personality.prompt)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var soulSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label("SOUL.md", systemImage: "sparkles")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if editingSOUL {
|
||||
Button("Cancel") {
|
||||
editingSOUL = false
|
||||
soulDraft = viewModel.soulMarkdown
|
||||
}
|
||||
.controlSize(.small)
|
||||
Button("Save") {
|
||||
viewModel.saveSOUL(soulDraft)
|
||||
editingSOUL = false
|
||||
}
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
} else {
|
||||
Button("Edit") { editingSOUL = true }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if editingSOUL {
|
||||
TextEditor(text: $soulDraft)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 220)
|
||||
.padding(6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DiscordSetupViewModel {
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
var allowBots: String = "none" // "none" | "mentions" | "all"
|
||||
var replyToMode: String = "first" // "off" | "first" | "all"
|
||||
|
||||
// config.yaml — these mirror the existing `HermesConfig.discord` block so we
|
||||
// stay consistent with whatever the Settings UI shows.
|
||||
var requireMention: Bool = true
|
||||
var freeResponseChannels: String = ""
|
||||
var autoThread: Bool = true
|
||||
var reactions: Bool = true
|
||||
|
||||
var message: String?
|
||||
|
||||
let allowBotsOptions = ["none", "mentions", "all"]
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
|
||||
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
|
||||
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().discord
|
||||
requireMention = cfg.requireMention
|
||||
freeResponseChannels = cfg.freeResponseChannels
|
||||
autoThread = cfg.autoThread
|
||||
reactions = cfg.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"DISCORD_BOT_TOKEN": botToken,
|
||||
"DISCORD_ALLOWED_USERS": allowedUsers,
|
||||
"DISCORD_HOME_CHANNEL": homeChannel,
|
||||
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
|
||||
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
|
||||
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"discord.free_response_channels": freeResponseChannels,
|
||||
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Email setup. IMAP/SMTP with app passwords — no OAuth.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EmailSetupViewModel {
|
||||
var address: String = ""
|
||||
var password: String = ""
|
||||
var imapHost: String = ""
|
||||
var smtpHost: String = ""
|
||||
var imapPort: String = "993"
|
||||
var smtpPort: String = "587"
|
||||
var pollInterval: String = "15"
|
||||
var allowedUsers: String = ""
|
||||
var homeAddress: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var skipAttachments: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
|
||||
struct Preset {
|
||||
let name: String
|
||||
let imap: String
|
||||
let smtp: String
|
||||
}
|
||||
let presets: [Preset] = [
|
||||
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
|
||||
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
|
||||
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
|
||||
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
|
||||
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
|
||||
]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
address = env["EMAIL_ADDRESS"] ?? ""
|
||||
password = env["EMAIL_PASSWORD"] ?? ""
|
||||
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
|
||||
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
|
||||
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
|
||||
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
|
||||
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
|
||||
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
|
||||
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
|
||||
// skip_attachments lives in config.yaml.
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
|
||||
}
|
||||
|
||||
func applyPreset(_ preset: Preset) {
|
||||
imapHost = preset.imap
|
||||
smtpHost = preset.smtp
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"EMAIL_ADDRESS": address,
|
||||
"EMAIL_PASSWORD": password,
|
||||
"EMAIL_IMAP_HOST": imapHost,
|
||||
"EMAIL_SMTP_HOST": smtpHost,
|
||||
"EMAIL_IMAP_PORT": imapPort,
|
||||
"EMAIL_SMTP_PORT": smtpPort,
|
||||
"EMAIL_POLL_INTERVAL": pollInterval,
|
||||
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"EMAIL_HOME_ADDRESS": homeAddress,
|
||||
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeishuSetupViewModel {
|
||||
var appID: String = ""
|
||||
var appSecret: String = ""
|
||||
var domain: String = "lark"
|
||||
var encryptKey: String = ""
|
||||
var verificationToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var connectionMode: String = "websocket" // "websocket" | "webhook"
|
||||
|
||||
var message: String?
|
||||
|
||||
let domainOptions = ["feishu", "lark"]
|
||||
let connectionOptions = ["websocket", "webhook"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
appID = env["FEISHU_APP_ID"] ?? ""
|
||||
appSecret = env["FEISHU_APP_SECRET"] ?? ""
|
||||
domain = env["FEISHU_DOMAIN"] ?? "lark"
|
||||
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
|
||||
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
|
||||
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
|
||||
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"FEISHU_APP_ID": appID,
|
||||
"FEISHU_APP_SECRET": appSecret,
|
||||
"FEISHU_DOMAIN": domain,
|
||||
"FEISHU_ENCRYPT_KEY": encryptKey,
|
||||
"FEISHU_VERIFICATION_TOKEN": verificationToken,
|
||||
"FEISHU_ALLOWED_USERS": allowedUsers,
|
||||
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
|
||||
/// `hermes config set` under `platforms.homeassistant.extra.*`.
|
||||
///
|
||||
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
|
||||
/// NOT editable in the form. `hermes config set` stores array arguments as
|
||||
/// quoted strings instead of YAML lists, which hermes would then reject as
|
||||
/// invalid. Users edit these directly in config.yaml — the view shows the
|
||||
/// current values (read-only) and an "Edit in config.yaml" button that opens
|
||||
/// the file.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HomeAssistantSetupViewModel {
|
||||
var url: String = "http://homeassistant.local:8123"
|
||||
var token: String = ""
|
||||
|
||||
// Scalar filters — writable via hermes config set.
|
||||
var watchAll: Bool = false
|
||||
var cooldownSeconds: Int = 30
|
||||
|
||||
// List filters — read-only; user must edit config.yaml manually.
|
||||
var watchDomains: [String] = []
|
||||
var watchEntities: [String] = []
|
||||
var ignoreEntities: [String] = []
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
|
||||
token = env["HASS_TOKEN"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().homeAssistant
|
||||
watchAll = cfg.watchAll
|
||||
cooldownSeconds = cfg.cooldownSeconds
|
||||
watchDomains = cfg.watchDomains
|
||||
watchEntities = cfg.watchEntities
|
||||
ignoreEntities = cfg.ignoreEntities
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"HASS_URL": url,
|
||||
"HASS_TOKEN": token
|
||||
]
|
||||
// Only scalar config values — lists are skipped intentionally; see
|
||||
// file header comment for rationale.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
|
||||
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Open config.yaml in the user's default editor so they can manually edit
|
||||
/// the list-valued filter fields.
|
||||
func openConfigForLists() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
|
||||
/// that's always on, with an Apple ID signed into Messages.app.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
|
||||
@Observable
|
||||
@MainActor
|
||||
final class IMessageSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var password: String = ""
|
||||
var webhookHost: String = "127.0.0.1"
|
||||
var webhookPort: String = "8645"
|
||||
var webhookPath: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
var sendReadReceipts: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
|
||||
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
|
||||
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
|
||||
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
|
||||
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
|
||||
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
|
||||
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"BLUEBUBBLES_SERVER_URL": serverURL,
|
||||
"BLUEBUBBLES_PASSWORD": password,
|
||||
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
|
||||
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
|
||||
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
|
||||
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
|
||||
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
|
||||
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
/// Matrix setup. Supports both access-token and password auth. No SSO.
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MatrixSetupViewModel {
|
||||
var homeserver: String = ""
|
||||
var accessToken: String = "" // preferred
|
||||
var userID: String = ""
|
||||
var password: String = "" // alternative to accessToken
|
||||
var allowedUsers: String = ""
|
||||
var homeRoom: String = ""
|
||||
var recoveryKey: String = ""
|
||||
var encryption: Bool = false
|
||||
|
||||
// config.yaml
|
||||
var requireMention: Bool = true
|
||||
var autoThread: Bool = true
|
||||
var dmMentionThreads: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
homeserver = env["MATRIX_HOMESERVER"] ?? ""
|
||||
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
|
||||
userID = env["MATRIX_USER_ID"] ?? ""
|
||||
password = env["MATRIX_PASSWORD"] ?? ""
|
||||
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
|
||||
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
|
||||
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
|
||||
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
|
||||
|
||||
let cfg = HermesFileService().loadConfig().matrix
|
||||
requireMention = cfg.requireMention
|
||||
autoThread = cfg.autoThread
|
||||
dmMentionThreads = cfg.dmMentionThreads
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATRIX_HOMESERVER": homeserver,
|
||||
"MATRIX_ACCESS_TOKEN": accessToken,
|
||||
"MATRIX_USER_ID": userID,
|
||||
"MATRIX_PASSWORD": password,
|
||||
"MATRIX_ALLOWED_USERS": allowedUsers,
|
||||
"MATRIX_HOME_ROOM": homeRoom,
|
||||
"MATRIX_RECOVERY_KEY": recoveryKey,
|
||||
"MATRIX_ENCRYPTION": encryption ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
|
||||
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Mattermost setup. Server URL + personal access token (or bot token).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
|
||||
@Observable
|
||||
@MainActor
|
||||
final class MattermostSetupViewModel {
|
||||
var serverURL: String = ""
|
||||
var token: String = ""
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var freeResponseChannels: String = ""
|
||||
|
||||
var replyMode: String = "off"
|
||||
var requireMention: Bool = true
|
||||
|
||||
var message: String?
|
||||
let replyModeOptions = ["off", "thread"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
serverURL = env["MATTERMOST_URL"] ?? ""
|
||||
token = env["MATTERMOST_TOKEN"] ?? ""
|
||||
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
|
||||
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
|
||||
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
|
||||
|
||||
let cfg = HermesFileService().loadConfig().mattermost
|
||||
requireMention = cfg.requireMention
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"MATTERMOST_URL": serverURL,
|
||||
"MATTERMOST_TOKEN": token,
|
||||
"MATTERMOST_ALLOWED_USERS": allowedUsers,
|
||||
"MATTERMOST_HOME_CHANNEL": homeChannel,
|
||||
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
|
||||
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
|
||||
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
|
||||
/// Shared helpers used by every per-platform setup view model.
|
||||
///
|
||||
/// Each platform form follows the same pattern:
|
||||
/// 1. Load current values from `.env` + config.yaml into local `@Observable` state.
|
||||
/// 2. Present them in a form where changes happen in-memory.
|
||||
/// 3. On save, write env vars via `HermesEnvService.setMany` and config.yaml keys
|
||||
/// via `hermes config set`, then surface a success/error toast.
|
||||
///
|
||||
/// Putting the save logic here keeps each per-platform VM focused on its own
|
||||
/// field set without re-implementing the write plumbing 12 times.
|
||||
@MainActor
|
||||
enum PlatformSetupHelpers {
|
||||
static let logger = Logger(subsystem: "com.scarf", category: "PlatformSetup")
|
||||
static let envService = HermesEnvService()
|
||||
|
||||
/// Apply a form save in one atomic batch.
|
||||
///
|
||||
/// - `envPairs`: values to write into `.env`. Empty strings trigger `unset()`
|
||||
/// (commenting the line out) rather than storing a literal empty value.
|
||||
/// - `configKV`: scalar config.yaml paths to set via `hermes config set`.
|
||||
/// Empty strings still produce a `config set <key> ""` call because
|
||||
/// some fields accept an explicit empty string (e.g., `display.skin: ""`).
|
||||
///
|
||||
/// Returns a user-facing summary message.
|
||||
@discardableResult
|
||||
static func saveForm(envPairs: [String: String], configKV: [String: String]) -> String {
|
||||
// Split env pairs into set vs. unset.
|
||||
var toSet: [String: String] = [:]
|
||||
var toUnset: [String] = []
|
||||
for (k, v) in envPairs {
|
||||
if v.isEmpty {
|
||||
toUnset.append(k)
|
||||
} else {
|
||||
toSet[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var envOK = true
|
||||
if !toSet.isEmpty {
|
||||
envOK = envService.setMany(toSet)
|
||||
}
|
||||
for key in toUnset {
|
||||
_ = envService.unset(key)
|
||||
}
|
||||
|
||||
var configFailures: [String] = []
|
||||
for (key, value) in configKV {
|
||||
let result = runHermesCLI(args: ["config", "set", key, value])
|
||||
if result.exitCode != 0 {
|
||||
configFailures.append(key)
|
||||
logger.warning("hermes config set \(key) failed: \(result.output)")
|
||||
}
|
||||
}
|
||||
|
||||
if !envOK { return "Failed to write .env" }
|
||||
if !configFailures.isEmpty { return "Saved, but failed to update: \(configFailures.joined(separator: ", "))" }
|
||||
return "Saved — restart gateway to apply"
|
||||
}
|
||||
|
||||
/// Synchronous hermes CLI invocation. Use only for fast commands like
|
||||
/// `config set`; longer commands should use `HermesFileService.runHermesCLI`
|
||||
/// from a `Task.detached`.
|
||||
static func runHermesCLI(args: [String], timeout: TimeInterval = 15) -> (exitCode: Int32, output: String) {
|
||||
HermesFileService().runHermesCLI(args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
/// Ask the user's default browser to open a URL (typically a hermes doc page
|
||||
/// or a platform developer portal).
|
||||
static func openURL(_ string: String) {
|
||||
guard let url = URL(string: string) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// Bool <-> "true"/"false" round-trip for env vars. Hermes accepts both
|
||||
/// "true"/"false" and "1"/"0"; we emit the string form for readability.
|
||||
static func envBool(_ on: Bool) -> String { on ? "true" : "false" }
|
||||
|
||||
/// Parse an env string as a bool. Treats missing/empty as `false`.
|
||||
/// "true", "1", "yes", "on" (case-insensitive) are true.
|
||||
static func parseEnvBool(_ s: String?) -> Bool {
|
||||
guard let s else { return false }
|
||||
switch s.lowercased() {
|
||||
case "true", "1", "yes", "on": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Signal setup. Users must install `signal-cli` externally (needs Java), link
|
||||
/// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port
|
||||
/// that hermes talks to. We expose an embedded terminal for both the link and
|
||||
/// daemon commands.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/signal
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SignalSetupViewModel {
|
||||
var httpURL: String = "http://127.0.0.1:8080"
|
||||
var account: String = "" // E.164 phone, e.g. +15551234567
|
||||
var allowedUsers: String = ""
|
||||
var groupAllowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var signalCLIInstalled: Bool = false
|
||||
var activeTask: SignalTerminalTask = .none
|
||||
|
||||
enum SignalTerminalTask: Equatable {
|
||||
case none
|
||||
case link
|
||||
case daemon
|
||||
}
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
httpURL = env["SIGNAL_HTTP_URL"] ?? "http://127.0.0.1:8080"
|
||||
account = env["SIGNAL_ACCOUNT"] ?? ""
|
||||
allowedUsers = env["SIGNAL_ALLOWED_USERS"] ?? ""
|
||||
groupAllowedUsers = env["SIGNAL_GROUP_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SIGNAL_HOME_CHANNEL"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["SIGNAL_ALLOW_ALL_USERS"])
|
||||
signalCLIInstalled = Self.detectSignalCLI()
|
||||
}
|
||||
|
||||
/// Best-effort `signal-cli` binary lookup on the login-shell PATH.
|
||||
private static func detectSignalCLI() -> Bool {
|
||||
let env = HermesFileService.enrichedEnvironment()
|
||||
let paths = env["PATH"]?.split(separator: ":").map(String.init) ?? []
|
||||
for dir in paths {
|
||||
if FileManager.default.isExecutableFile(atPath: dir + "/signal-cli") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SIGNAL_HTTP_URL": httpURL,
|
||||
"SIGNAL_ACCOUNT": account,
|
||||
"SIGNAL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
|
||||
"SIGNAL_GROUP_ALLOWED_USERS": groupAllowedUsers,
|
||||
"SIGNAL_HOME_CHANNEL": homeChannel,
|
||||
"SIGNAL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Run `signal-cli link -n HermesAgent` to generate a QR code.
|
||||
func startLink() {
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH — install it first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .link
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
self?.message = "Link step exited — save credentials and start the daemon next"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(executable: "/usr/bin/env", arguments: ["signal-cli", "link", "-n", "HermesAgent"])
|
||||
}
|
||||
|
||||
/// Run the signal-cli daemon. Users can stop it by closing the panel.
|
||||
func startDaemon() {
|
||||
guard !account.isEmpty else {
|
||||
message = "Enter your Signal account (E.164 format) first"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
guard signalCLIInstalled else {
|
||||
message = "signal-cli not found on PATH"
|
||||
clearMessageAfterDelay()
|
||||
return
|
||||
}
|
||||
activeTask = .daemon
|
||||
let bind = httpURL.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.activeTask = .none
|
||||
}
|
||||
terminalController.start(
|
||||
executable: "/usr/bin/env",
|
||||
arguments: ["signal-cli", "--account", account, "daemon", "--http", bind]
|
||||
)
|
||||
}
|
||||
|
||||
func stopTerminal() {
|
||||
terminalController.stop()
|
||||
activeTask = .none
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
/// Slack setup. Requires two tokens (bot + app-level for Socket Mode).
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack
|
||||
@Observable
|
||||
@MainActor
|
||||
final class SlackSetupViewModel {
|
||||
var botToken: String = "" // xoxb-...
|
||||
var appToken: String = "" // xapp-...
|
||||
var allowedUsers: String = ""
|
||||
var homeChannel: String = ""
|
||||
var homeChannelName: String = ""
|
||||
|
||||
var replyToMode: String = "first"
|
||||
var requireMention: Bool = true
|
||||
var replyInThread: Bool = true
|
||||
var replyBroadcast: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
let replyToModeOptions = ["off", "first", "all"]
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["SLACK_BOT_TOKEN"] ?? ""
|
||||
appToken = env["SLACK_APP_TOKEN"] ?? ""
|
||||
allowedUsers = env["SLACK_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["SLACK_HOME_CHANNEL"] ?? ""
|
||||
homeChannelName = env["SLACK_HOME_CHANNEL_NAME"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig().slack
|
||||
replyToMode = cfg.replyToMode
|
||||
requireMention = cfg.requireMention
|
||||
replyInThread = cfg.replyInThread
|
||||
replyBroadcast = cfg.replyBroadcast
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"SLACK_BOT_TOKEN": botToken,
|
||||
"SLACK_APP_TOKEN": appToken,
|
||||
"SLACK_ALLOWED_USERS": allowedUsers,
|
||||
"SLACK_HOME_CHANNEL": homeChannel,
|
||||
"SLACK_HOME_CHANNEL_NAME": homeChannelName
|
||||
]
|
||||
// Slack uses the modern `platforms.slack.*` schema.
|
||||
let configKV: [String: String] = [
|
||||
"platforms.slack.reply_to_mode": replyToMode,
|
||||
"platforms.slack.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"platforms.slack.extra.reply_in_thread": PlatformSetupHelpers.envBool(replyInThread),
|
||||
"platforms.slack.extra.reply_broadcast": PlatformSetupHelpers.envBool(replyBroadcast)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention /
|
||||
/// reactions toggles live in `config.yaml` under `telegram.*`.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TelegramSetupViewModel {
|
||||
// Required
|
||||
var botToken: String = ""
|
||||
var allowedUsers: String = ""
|
||||
// Optional
|
||||
var homeChannel: String = ""
|
||||
var webhookURL: String = ""
|
||||
var webhookPort: String = ""
|
||||
var webhookSecret: String = ""
|
||||
// Config.yaml toggles
|
||||
var requireMention: Bool = true
|
||||
var reactions: Bool = false
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
botToken = env["TELEGRAM_BOT_TOKEN"] ?? ""
|
||||
allowedUsers = env["TELEGRAM_ALLOWED_USERS"] ?? ""
|
||||
homeChannel = env["TELEGRAM_HOME_CHANNEL"] ?? ""
|
||||
webhookURL = env["TELEGRAM_WEBHOOK_URL"] ?? ""
|
||||
webhookPort = env["TELEGRAM_WEBHOOK_PORT"] ?? ""
|
||||
webhookSecret = env["TELEGRAM_WEBHOOK_SECRET"] ?? ""
|
||||
|
||||
let cfg = HermesFileService().loadConfig()
|
||||
requireMention = cfg.telegram.requireMention
|
||||
reactions = cfg.telegram.reactions
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"TELEGRAM_BOT_TOKEN": botToken,
|
||||
"TELEGRAM_ALLOWED_USERS": allowedUsers,
|
||||
"TELEGRAM_HOME_CHANNEL": homeChannel,
|
||||
"TELEGRAM_WEBHOOK_URL": webhookURL,
|
||||
"TELEGRAM_WEBHOOK_PORT": webhookPort,
|
||||
"TELEGRAM_WEBHOOK_SECRET": webhookSecret
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"telegram.require_mention": PlatformSetupHelpers.envBool(requireMention),
|
||||
"telegram.reactions": PlatformSetupHelpers.envBool(reactions)
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Webhook platform setup. Just the global enable/port/secret — per-subscription
|
||||
/// routes live in the Webhooks sidebar feature.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WebhookSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var port: String = "8644"
|
||||
var secret: String = ""
|
||||
|
||||
var message: String?
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WEBHOOK_ENABLED"])
|
||||
port = env["WEBHOOK_PORT"] ?? "8644"
|
||||
secret = env["WEBHOOK_SECRET"] ?? ""
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WEBHOOK_ENABLED": enabled ? "true" : "",
|
||||
"WEBHOOK_PORT": port,
|
||||
"WEBHOOK_SECRET": secret
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: [:])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code
|
||||
/// via the `hermes whatsapp` CLI wizard — we expose that as an embedded
|
||||
/// terminal below the config form.
|
||||
///
|
||||
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp
|
||||
@Observable
|
||||
@MainActor
|
||||
final class WhatsAppSetupViewModel {
|
||||
var enabled: Bool = false
|
||||
var mode: String = "bot" // "bot" | "self-chat"
|
||||
var allowedUsers: String = "" // Comma-separated phone numbers (no +)
|
||||
var allowAllUsers: Bool = false
|
||||
|
||||
// config.yaml knobs
|
||||
var unauthorizedDMBehavior: String = "pair" // "pair" | "ignore"
|
||||
var replyPrefix: String = ""
|
||||
|
||||
var message: String?
|
||||
let modeOptions = ["bot", "self-chat"]
|
||||
let unauthorizedOptions = ["pair", "ignore"]
|
||||
|
||||
/// The embedded terminal for the pairing step. Owned here so we can
|
||||
/// `stop()` it cleanly when the user navigates away.
|
||||
let terminalController = EmbeddedSetupTerminalController()
|
||||
var pairingInProgress: Bool = false
|
||||
|
||||
func load() {
|
||||
let env = HermesEnvService().load()
|
||||
enabled = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ENABLED"])
|
||||
mode = env["WHATSAPP_MODE"] ?? "bot"
|
||||
allowedUsers = env["WHATSAPP_ALLOWED_USERS"] ?? ""
|
||||
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["WHATSAPP_ALLOW_ALL_USERS"])
|
||||
// Hermes accepts two equivalent ways to mean "allow everyone":
|
||||
// WHATSAPP_ALLOW_ALL_USERS=true OR WHATSAPP_ALLOWED_USERS=*
|
||||
// Normalize so the checkbox reflects either form.
|
||||
if allowedUsers == "*" {
|
||||
allowAllUsers = true
|
||||
allowedUsers = ""
|
||||
}
|
||||
|
||||
let cfg = HermesFileService().loadConfig().whatsapp
|
||||
unauthorizedDMBehavior = cfg.unauthorizedDMBehavior
|
||||
replyPrefix = cfg.replyPrefix
|
||||
}
|
||||
|
||||
func save() {
|
||||
let envPairs: [String: String] = [
|
||||
"WHATSAPP_ENABLED": PlatformSetupHelpers.envBool(enabled),
|
||||
"WHATSAPP_MODE": mode,
|
||||
// If "allow all" is set, the allowlist becomes "*" per hermes docs.
|
||||
"WHATSAPP_ALLOWED_USERS": allowAllUsers ? "*" : allowedUsers,
|
||||
"WHATSAPP_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
|
||||
]
|
||||
let configKV: [String: String] = [
|
||||
"whatsapp.unauthorized_dm_behavior": unauthorizedDMBehavior,
|
||||
"whatsapp.reply_prefix": replyPrefix
|
||||
]
|
||||
message = PlatformSetupHelpers.saveForm(envPairs: envPairs, configKV: configKV)
|
||||
clearMessageAfterDelay()
|
||||
}
|
||||
|
||||
/// Launch `hermes whatsapp` in the embedded terminal. The user scans the QR
|
||||
/// code; hermes writes the session to `~/.hermes/platforms/whatsapp/session`
|
||||
/// and exits when pairing is complete.
|
||||
func startPairing() {
|
||||
pairingInProgress = true
|
||||
terminalController.onExit = { [weak self] _ in
|
||||
self?.pairingInProgress = false
|
||||
self?.message = "Pairing terminal exited — check output for status"
|
||||
self?.clearMessageAfterDelay()
|
||||
}
|
||||
terminalController.start(
|
||||
executable: HermesPaths.hermesBinary,
|
||||
arguments: ["whatsapp"]
|
||||
)
|
||||
}
|
||||
|
||||
func stopPairing() {
|
||||
terminalController.stop()
|
||||
pairingInProgress = false
|
||||
}
|
||||
|
||||
private func clearMessageAfterDelay() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Platform list/selection coordinator. Per-platform configuration now lives in
|
||||
/// dedicated `<Platform>SetupViewModel` classes under `ViewModels/PlatformSetup/`.
|
||||
/// This VM only manages the sidebar list, connectivity detection, and the
|
||||
/// "Restart Gateway" action.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PlatformsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "PlatformsViewModel")
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var gatewayState: GatewayState?
|
||||
var selected: HermesToolPlatform = KnownPlatforms.cli
|
||||
var message: String?
|
||||
var restartInProgress: Bool = false
|
||||
|
||||
var platforms: [HermesToolPlatform] { KnownPlatforms.all }
|
||||
|
||||
func load() {
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
}
|
||||
|
||||
func connectivity(for platform: HermesToolPlatform) -> PlatformConnectivity {
|
||||
if let pState = gatewayState?.platforms?[platform.name] {
|
||||
if let err = pState.error, !err.isEmpty { return .error(err) }
|
||||
if pState.connected == true { return .connected }
|
||||
}
|
||||
return hasConfigBlock(for: platform) ? .configured : .notConfigured
|
||||
}
|
||||
|
||||
/// Does the platform have any configuration on disk — either a top-level
|
||||
/// `<platform>:` block in config.yaml, or an "identifying" env var in
|
||||
/// `.env` (e.g. `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`)?
|
||||
///
|
||||
/// We need the env-var check because the new per-platform setup forms
|
||||
/// write credentials to `.env` primarily; most platforms don't create a
|
||||
/// YAML block until the user saves a behavior toggle. Without this,
|
||||
/// platforms configured via the new flow would display as "Not configured"
|
||||
/// until the first YAML edit.
|
||||
func hasConfigBlock(for platform: HermesToolPlatform) -> Bool {
|
||||
if platform.name == "cli" { return true }
|
||||
let yaml = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
for line in yaml.components(separatedBy: "\n") where !line.hasPrefix(" ") && !line.hasPrefix("\t") {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "\(platform.name):" { return true }
|
||||
}
|
||||
// Env-var fallback: any identifying env var for this platform counts
|
||||
// as "configured". Uses the shared `identifyingEnvVar(for:)` mapping.
|
||||
if let key = Self.identifyingEnvVar(for: platform.name) {
|
||||
let env = HermesEnvService().load()
|
||||
if let value = env[key], !value.isEmpty { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Primary credential env var for a platform — the one whose presence
|
||||
/// signals that the user has started setup. Centralized here so both the
|
||||
/// connectivity detector and future diagnostics agree on the check.
|
||||
private static func identifyingEnvVar(for platformName: String) -> String? {
|
||||
switch platformName {
|
||||
case "telegram": return "TELEGRAM_BOT_TOKEN"
|
||||
case "discord": return "DISCORD_BOT_TOKEN"
|
||||
case "slack": return "SLACK_BOT_TOKEN"
|
||||
case "whatsapp": return "WHATSAPP_ENABLED"
|
||||
case "signal": return "SIGNAL_ACCOUNT"
|
||||
case "email": return "EMAIL_ADDRESS"
|
||||
case "matrix": return "MATRIX_HOMESERVER"
|
||||
case "mattermost": return "MATTERMOST_URL"
|
||||
case "feishu": return "FEISHU_APP_ID"
|
||||
case "imessage": return "BLUEBUBBLES_SERVER_URL"
|
||||
case "homeassistant": return "HASS_TOKEN"
|
||||
case "webhook": return "WEBHOOK_ENABLED"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the hermes gateway so newly-saved config takes effect. Runs on a
|
||||
/// background task so the UI stays responsive during the ~second or two
|
||||
/// `hermes gateway restart` takes.
|
||||
func restartGateway() {
|
||||
restartInProgress = true
|
||||
message = "Restarting gateway…"
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["gateway", "restart"], timeout: 30)
|
||||
await MainActor.run {
|
||||
self.restartInProgress = false
|
||||
self.message = result.exitCode == 0 ? "Gateway restarted" : "Restart failed"
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DiscordSetupView: View {
|
||||
@State private var viewModel = DiscordSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Required", icon: "key") {
|
||||
SecretTextField(label: "Bot Token", value: viewModel.botToken) { viewModel.botToken = $0 }
|
||||
EditableTextField(label: "Allowed User IDs", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Home Channel", icon: "house") {
|
||||
EditableTextField(label: "Home Channel ID", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
EditableTextField(label: "Display Name", value: viewModel.homeChannelName) { viewModel.homeChannelName = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Require @mention", isOn: viewModel.requireMention) { viewModel.requireMention = $0 }
|
||||
EditableTextField(label: "Free-Response Channels", value: viewModel.freeResponseChannels) { viewModel.freeResponseChannels = $0 }
|
||||
ToggleRow(label: "Auto-thread on mention", isOn: viewModel.autoThread) { viewModel.autoThread = $0 }
|
||||
ToggleRow(label: "Reactions", isOn: viewModel.reactions) { viewModel.reactions = $0 }
|
||||
PickerRow(label: "Allow Other Bots", selection: viewModel.allowBots, options: viewModel.allowBotsOptions) { viewModel.allowBots = $0 }
|
||||
PickerRow(label: "Reply Mode", selection: viewModel.replyToMode, options: viewModel.replyToModeOptions) { viewModel.replyToMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in Discord's Developer Portal, enable Message Content and Server Members intents, and copy the bot token. Invite the bot to your server via the OAuth2 URL generator.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Open Developer Portal") { PlatformSetupHelpers.openURL("https://discord.com/developers/applications") }
|
||||
.controlSize(.small)
|
||||
Button("Discord Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmailSetupView: View {
|
||||
@State private var viewModel = EmailSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
presetBar
|
||||
|
||||
SettingsSection(title: "Credentials", icon: "envelope") {
|
||||
EditableTextField(label: "Email Address", value: viewModel.address) { viewModel.address = $0 }
|
||||
SecretTextField(label: "App Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Servers", icon: "server.rack") {
|
||||
EditableTextField(label: "IMAP Host", value: viewModel.imapHost) { viewModel.imapHost = $0 }
|
||||
EditableTextField(label: "SMTP Host", value: viewModel.smtpHost) { viewModel.smtpHost = $0 }
|
||||
EditableTextField(label: "IMAP Port", value: viewModel.imapPort) { viewModel.imapPort = $0 }
|
||||
EditableTextField(label: "SMTP Port", value: viewModel.smtpPort) { viewModel.smtpPort = $0 }
|
||||
EditableTextField(label: "Poll Interval (s)", value: viewModel.pollInterval) { viewModel.pollInterval = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Senders", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Senders", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Address", value: viewModel.homeAddress) { viewModel.homeAddress = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Skip Attachments", isOn: viewModel.skipAttachments) { viewModel.skipAttachments = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Enable 2FA on your email account and generate an app password. Regular account passwords will fail. Always set allowed senders — otherwise anyone knowing the address can message the agent.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Email Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var presetBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text("Preset:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(viewModel.presets, id: \.name) { preset in
|
||||
Button(preset.name) { viewModel.applyPreset(preset) }
|
||||
.controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
/// Inline SwiftTerm terminal for platform pairing wizards that genuinely require
|
||||
/// a TTY (WhatsApp QR, Signal `signal-cli link`). This is a lightweight sibling
|
||||
/// to `PersistentTerminalView` in the Chat feature — scoped to run a single
|
||||
/// command, show its output, and notify when the process exits.
|
||||
///
|
||||
/// Usage:
|
||||
/// EmbeddedSetupTerminal(controller: viewModel.terminalController)
|
||||
/// // Controller exposes start()/terminate() that the view model owns.
|
||||
struct EmbeddedSetupTerminal: NSViewRepresentable {
|
||||
let controller: EmbeddedSetupTerminalController
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let container = NSView()
|
||||
controller.attach(to: container)
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// If the view model recreated its terminal view (e.g., after re-launching
|
||||
// the pairing command), re-attach it to the container.
|
||||
controller.reattachIfNeeded(to: nsView)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the `LocalProcessTerminalView` so it survives SwiftUI body redraws.
|
||||
/// Lives on the view model (one per platform that uses it).
|
||||
@MainActor
|
||||
final class EmbeddedSetupTerminalController {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "EmbeddedSetupTerminal")
|
||||
|
||||
/// The hosting NSView from the `NSViewRepresentable`. Weak because SwiftUI
|
||||
/// owns the container's lifetime; we just attach our terminal view inside it.
|
||||
private weak var container: NSView?
|
||||
|
||||
/// The actual terminal emulator. Recreated per launch so a terminated
|
||||
/// process doesn't leave stale buffer state mixed with new output.
|
||||
private var terminalView: LocalProcessTerminalView?
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Invoked when the spawned process exits. The `Int32` is the exit code
|
||||
/// (`0` success, non-zero failure). Runs on the main actor.
|
||||
var onExit: ((Int32) -> Void)?
|
||||
|
||||
var isRunning: Bool { terminalView != nil }
|
||||
|
||||
/// Start a process in the embedded terminal. If a process is already running,
|
||||
/// it is terminated first to avoid orphans.
|
||||
func start(executable: String, arguments: [String], environment: [String: String] = [:]) {
|
||||
stop()
|
||||
guard let container else {
|
||||
logger.warning("start() called before terminal was attached to a container")
|
||||
return
|
||||
}
|
||||
|
||||
let terminal = LocalProcessTerminalView(frame: .zero)
|
||||
terminal.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)
|
||||
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)
|
||||
|
||||
let coord = Coordinator { [weak self] exitCode in
|
||||
self?.onExit?(exitCode ?? -1)
|
||||
}
|
||||
terminal.processDelegate = coord
|
||||
coordinator = coord
|
||||
|
||||
// Merge caller-provided env over the enriched shell env so `npx`, `node`,
|
||||
// `signal-cli`, etc. resolve from PATH.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["COLORTERM"] = "truecolor"
|
||||
for (k, v) in environment { env[k] = v }
|
||||
let envArray = env.map { "\($0.key)=\($0.value)" }
|
||||
|
||||
terminal.startProcess(
|
||||
executable: executable,
|
||||
args: arguments,
|
||||
environment: envArray,
|
||||
execName: nil
|
||||
)
|
||||
|
||||
// Attach with AutoLayout constraints — matches the pattern used by
|
||||
// Features/Chat/Views/TerminalRepresentable.swift. Relying on
|
||||
// autoresizingMask is unreliable when SwiftUI hosts the NSView,
|
||||
// because SwiftUI drives layout via AutoLayout.
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
terminal.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(terminal)
|
||||
NSLayoutConstraint.activate([
|
||||
terminal.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
terminal.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
terminal.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
terminal.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
terminalView = terminal
|
||||
}
|
||||
|
||||
/// Kill the running process (if any). Safe to call when nothing is running.
|
||||
func stop() {
|
||||
terminalView?.terminate()
|
||||
terminalView?.removeFromSuperview()
|
||||
terminalView = nil
|
||||
}
|
||||
|
||||
// MARK: - NSViewRepresentable plumbing
|
||||
|
||||
func attach(to container: NSView) {
|
||||
self.container = container
|
||||
if let tv = terminalView, tv.superview !== container {
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func reattachIfNeeded(to container: NSView) {
|
||||
self.container = container
|
||||
guard let tv = terminalView, tv.superview !== container else { return }
|
||||
container.subviews.forEach { $0.removeFromSuperview() }
|
||||
tv.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(tv)
|
||||
NSLayoutConstraint.activate([
|
||||
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
tv.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||
let onTerminated: (Int32?) -> Void
|
||||
|
||||
init(onTerminated: @escaping (Int32?) -> Void) {
|
||||
self.onTerminated = onTerminated
|
||||
}
|
||||
|
||||
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
|
||||
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {}
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
|
||||
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||
let terminal = source.getTerminal()
|
||||
terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n")
|
||||
let code = exitCode
|
||||
DispatchQueue.main.async { self.onTerminated(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeishuSetupView: View {
|
||||
@State private var viewModel = FeishuSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "App Credentials", icon: "key") {
|
||||
EditableTextField(label: "App ID", value: viewModel.appID) { viewModel.appID = $0 }
|
||||
SecretTextField(label: "App Secret", value: viewModel.appSecret) { viewModel.appSecret = $0 }
|
||||
PickerRow(label: "Domain", selection: viewModel.domain, options: viewModel.domainOptions) { viewModel.domain = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook Security", icon: "lock.shield") {
|
||||
SecretTextField(label: "Encrypt Key", value: viewModel.encryptKey) { viewModel.encryptKey = $0 }
|
||||
SecretTextField(label: "Verification Token", value: viewModel.verificationToken) { viewModel.verificationToken = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
PickerRow(label: "Connection Mode", selection: viewModel.connectionMode, options: viewModel.connectionOptions) { viewModel.connectionMode = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create an app in the Feishu/Lark Developer Console, enable Interactive Card if you need button responses, and copy the App ID and App Secret. WebSocket mode (recommended) doesn't need a public endpoint.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Feishu Setup Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeAssistantSetupView: View {
|
||||
@State private var viewModel = HomeAssistantSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "Connection", icon: "network") {
|
||||
EditableTextField(label: "URL", value: viewModel.url) { viewModel.url = $0 }
|
||||
SecretTextField(label: "Long-Lived Token", value: viewModel.token) { viewModel.token = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Event Filters", icon: "line.3.horizontal.decrease.circle") {
|
||||
ToggleRow(label: "Watch All Changes", isOn: viewModel.watchAll) { viewModel.watchAll = $0 }
|
||||
StepperRow(label: "Cooldown (s)", value: viewModel.cooldownSeconds, range: 0...3600, step: 5) { viewModel.cooldownSeconds = $0 }
|
||||
}
|
||||
|
||||
listFiltersSection
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Create a long-lived access token in Home Assistant (Profile → Security → Long-Lived Access Tokens). By default, no events are forwarded — enable Watch All Changes, or add entity filters below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Home Assistant Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only display of list-valued filters (watch_domains, watch_entities,
|
||||
/// ignore_entities). Editing requires hand-modifying config.yaml because
|
||||
/// the `hermes config set` CLI can't produce YAML lists — it stores
|
||||
/// arrays as quoted strings, which hermes rejects.
|
||||
private var listFiltersSection: some View {
|
||||
SettingsSection(title: "Entity Filters (config.yaml only)", icon: "list.bullet") {
|
||||
ReadOnlyRow(label: "Watch Domains", value: viewModel.watchDomains.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Watch Entities", value: viewModel.watchEntities.joined(separator: ", "))
|
||||
ReadOnlyRow(label: "Ignore Entities", value: viewModel.ignoreEntities.joined(separator: ", "))
|
||||
HStack {
|
||||
Text("")
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text("These list fields must be edited directly in config.yaml.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Edit config.yaml") { viewModel.openConfigForLists() }
|
||||
.controlSize(.mini)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IMessageSetupView: View {
|
||||
@State private var viewModel = IMessageSetupViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
instructions
|
||||
|
||||
SettingsSection(title: "BlueBubbles Server", icon: "server.rack") {
|
||||
EditableTextField(label: "Server URL", value: viewModel.serverURL) { viewModel.serverURL = $0 }
|
||||
SecretTextField(label: "Server Password", value: viewModel.password) { viewModel.password = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Webhook (hermes side)", icon: "arrow.up.right.square") {
|
||||
EditableTextField(label: "Host", value: viewModel.webhookHost) { viewModel.webhookHost = $0 }
|
||||
EditableTextField(label: "Port", value: viewModel.webhookPort) { viewModel.webhookPort = $0 }
|
||||
EditableTextField(label: "Path", value: viewModel.webhookPath) { viewModel.webhookPath = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Access Control", icon: "person.badge.shield.checkmark") {
|
||||
ToggleRow(label: "Allow All Users", isOn: viewModel.allowAllUsers) { viewModel.allowAllUsers = $0 }
|
||||
if !viewModel.allowAllUsers {
|
||||
EditableTextField(label: "Allowed Users", value: viewModel.allowedUsers) { viewModel.allowedUsers = $0 }
|
||||
}
|
||||
EditableTextField(label: "Home Channel", value: viewModel.homeChannel) { viewModel.homeChannel = $0 }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Behavior", icon: "slider.horizontal.3") {
|
||||
ToggleRow(label: "Send Read Receipts", isOn: viewModel.sendReadReceipts) { viewModel.sendReadReceipts = $0 }
|
||||
}
|
||||
|
||||
saveBar
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var instructions: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("iMessage integration runs through BlueBubbles Server. You need a Mac that stays on with Messages.app signed in — install BlueBubbles Server on it, then point hermes at that server's URL.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
Button("Install BlueBubbles Server") { PlatformSetupHelpers.openURL("https://bluebubbles.app/") }
|
||||
.controlSize(.small)
|
||||
Button("BlueBubbles Docs") { PlatformSetupHelpers.openURL("https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles") }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.message {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Reload") { viewModel.load() }.controlSize(.small)
|
||||
Button("Save") { viewModel.save() }.buttonStyle(.borderedProminent).controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user