Compare commits

...

52 Commits

Author SHA1 Message Date
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
  or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
  tool include/exclude filters, resources/prompts toggles, request
  and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
  the discovered tool list
- Gateway-restart banner after config changes that need a reload

README updated with the MCP Servers section, the new MCPServers/
feature module entry, and the `hermes mcp` + `mcp-tokens/` entries
in the Data Sources table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test,
enable/disable. YAML config patching for args, env, headers, tool
filters, timeouts. OAuth token detection + deletion. Preset picker
for common MCP servers. Gateway restart banner after config changes.

New sidebar section "MCP Servers" under Manage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:53:55 -07:00
Alan Wizemann c7e6a809ed chore: Bump version to 1.5.5 and add release binaries
Ship Hermes v0.9.0 compatibility plus new features (log component
filter, session pill, Fast Mode, Backup/Restore, iMessage, /compress,
Discord threads). README lists both universal and ARM64 downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:32:16 -07:00
Alan Wizemann c5d6116f99 feat: Add Hermes v0.9.0 compatibility and new feature surfaces
- Log parser: session-ID tag in v0.9.0 log format is now an optional
  capture group; session pill renders inline and tap-filters the view.
- Logs: component filter (Gateway/Agent/Tools/CLI/Cron) and bounded
  logger column with middle truncation.
- Gateway stop: uses `hermes gateway stop` CLI (v0.9.0's launchctl
  bootout fix) with SIGTERM as fallback.
- HermesConfig: new keys for Fast Mode (service_tier), gateway notify
  interval, force IPv4, context engine, interim assistant messages,
  and Honcho eager init (camelCase per PR #6995).
- Settings: new Performance, Network, Advanced, and Backup & Restore
  sections that call `hermes backup` / `hermes import` off the main
  actor; robust zip-path extraction via regex.
- Platforms: iMessage (BlueBubbles) added to KnownPlatforms and
  icon map.
- Cron: Discord thread delivery (`discord:chat:thread`) renders as
  "Discord thread X in Y".
- Chat: `/compress <focus>` button appears when ACP advertises the
  command; optional focus sheet sends through existing prompt path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:59:46 -07:00
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
Alan Wizemann 86762eab6d fix: Harden ACP session stability and recover messages on reconnection
Sessions were silently dying and losing chat history because:
- Pipe write errors (EPIPE) were completely undetected — broken pipe
  writes via Task.detached { handle.write() } failed silently, leaving
  the app unaware the subprocess had crashed
- Reconnection fell back to newSession() when loadSession() failed,
  creating a blank session and permanently losing all conversation context
- No message reconciliation after reconnect — DB-persisted messages
  were never re-fetched, so the UI stayed stale/incomplete
- Keepalive sent bare "\n" which caused json.loads("") parse errors
  in the ACP library every 30 seconds, destabilizing the connection
- TERM=xterm-256color was set on a pipe-based subprocess, risking
  terminal escape sequence pollution in the JSON-RPC stream

Fixes:
- Replace FileHandle.write() with POSIX Darwin.write() + SIGPIPE
  suppression for immediate broken-pipe detection at all write sites
- Send valid JSON-RPC notification {"jsonrpc":"2.0","method":"$/ping"}
  as keepalive instead of bare newlines
- Never fall back to newSession() during reconnection — try
  resumeSession then loadSession, fail visibly if both fail
- Add reconcileWithDB() to merge DB-persisted messages with local
  state after successful reconnection
- Finalize streaming messages immediately on disconnect so partial
  content is preserved before reconnection begins
- Use SIGINT instead of SIGTERM for graceful Python subprocess shutdown
- Remove TERM env var from ACP subprocess environment
- Consolidate disconnect cleanup into single idempotent method
- Add isHandlingDisconnect guard against double-handling
- Increase reconnect attempts from 3 to 5 with capped backoff
- Add "Reconnect" button to toolbar error state

Also: bump version to 1.5.1, set deployment target to macOS 14.6
(Sonoma), and update README with rich chat/ACP features, process
controls, skill editing, and corrected system requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:13:34 -04:00
Alan Wizemann a7fd193770 feat: Add ACP real-time chat with stable connection management
Implement a rich chat interface powered by the Hermes ACP (Agent
Communication Protocol) over JSON-RPC stdio pipes, with comprehensive
connection stability:

- ACPClient actor: manages hermes acp subprocess lifecycle, JSON-RPC
  transport, event streaming via AsyncStream, and session management
- ACPMessages: full event parsing for message chunks, thought chunks,
  tool calls, permission requests, and prompt completion
- RichChatViewModel: streaming message display with live updates,
  tool result rendering, and message grouping
- ChatViewModel: ACP session orchestration, auto-start on first
  message, and terminal mode fallback

Connection stability fixes:
- Non-blocking pipe writes via Task.detached to prevent actor deadlock
- Read loop cleanup (handleReadLoopEnded) finishes event stream and
  fails pending requests on EOF instead of hanging silently
- 30s request timeouts on control messages via watchdog Task pattern
- Keepalive: writes \n to stdin every 30s to detect dead processes
  via EPIPE before the next user action
- Health monitor: polls process.isRunning every 5s as belt-and-suspenders
- Auto-reconnect: retries up to 3 times with exponential backoff
  (1s/2s/4s), restores session, only shows error after all retries fail
- connectionLost event displays system message in chat on failure
- Proper stderr pipe management: stored task reference, closed in stop()
- Idempotent cleanup across handleReadLoopEnded, handleTermination,
  and handleConnectionDied via actor serialization and nil guards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 04:33:03 -04:00
Alan Wizemann 521c6d63fc refactor: Use MarkdownContentView in rich chat bubbles
Replace inline AttributedString(markdown:) in RichMessageBubble with
the shared MarkdownContentView for consistent styled rendering of
headers, lists, blockquotes, and inline formatting in chat messages.
Code blocks continue to use CodeBlockView with its copy button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:13:17 -04:00
Alan Wizemann 66d04d838d Merge branch 'chat-interface' into main
Add rich chat interface with iMessage-style message bubbles, terminal
toggle, session info bar, code block rendering with copy button, and
tool call cards. Supports both terminal and rich chat display modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:09:31 -04:00
Alan Wizemann ad30c0a943 feat: Show tool output in Activity inspector (#12)
Add tool result display to the Activity detail pane. When selecting a
tool call, the inspector now shows Arguments → Output → Assistant
Message, giving full visibility into what was requested, what came back,
and how the assistant interpreted it.

- Add fetchToolResult(callId:) query to HermesDataService
- Fetch tool result on entry selection in ActivityViewModel
- Display output in styled monospaced box in detail pane
- Render assistant message with MarkdownContentView

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:52:42 -04:00
Alan Wizemann 44afa8f53b fix: Hide false external memory provider warning on fresh installs
The config.yaml uses YAML empty string literal (provider: '') which the
parser reads as the literal string '' rather than an empty string. Strip
surrounding quotes before checking so '' and "" are treated as empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:40:35 -04:00
Alan Wizemann 481b937c33 feat: Add rich markdown rendering and skill editing (#11)
Add a custom MarkdownContentView that renders markdown with visual
styling — large headers, styled code blocks with language labels,
bullet and numbered lists, blockquotes with colored borders, and
horizontal rules. YAML frontmatter in skill files is hidden.

Markdown rendering added to:
- Memory view (MEMORY.md, USER.md) with live preview in editor
- Skills view (.md files) with new edit/save capability
- Session messages (assistant responses)
- Dashboard text widgets

Other changes:
- Shared MarkdownRenderer utility for inline formatting
- Split-pane editors (raw markdown left, live preview right)
- saveSkillContent() in HermesFileService with path validation
- Line breaks preserved in non-markdown content (Key: Value format)

Closes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:30:44 -04:00
Alan Wizemann 790efb585b feat: Add Hermes process start/stop/restart controls (#10)
- Add hermesPID() and stopHermes() to HermesFileService for process
  signal management via SIGTERM
- Add process control bar to Health view with running status, PID
  display, and Start/Stop/Restart buttons
- Add Start/Stop/Restart Hermes quick actions to menu bar
- Start launches gateway, stop sends SIGTERM, restart combines both

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:48:53 -04:00
Alan Wizemann 3acf95a824 feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)
Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

Bug fix:
- Fix Tools tab phantom toggling when switching platforms (#9)
  - Add .id() to tool list for proper SwiftUI view identity
  - Replace ambiguous plain buttons with segmented Picker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:12:13 -04:00
Alan Wizemann 7d69c82c2b Add rich chat interface with iMessage-style bubbles and terminal toggle
Introduce a new structured chat view as an alternative to the SwiftTerm
terminal. Users can switch between raw terminal and rich chat modes via a
segmented picker in the toolbar. The rich view polls state.db for messages
and renders them as conversation bubbles with markdown, code blocks,
expandable tool call cards, reasoning sections, and a live session info bar
showing tokens, cost, and model. The terminal process stays alive in both
modes — in rich mode it runs hidden while user input from the text field is
piped to its stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:33:55 -04:00
Alan Wizemann ae2872e08f Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection
- Auto-detect v0.7.0 database schema with backward compat for older DBs
- Surface reasoning tokens, actual cost, and billing provider from sessions
- Display model reasoning/thinking content in session message bubbles
- Add cost tracking to Dashboard, Insights, and session detail views
- Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0")
- Add missing platforms: Home Assistant, Webhook, Matrix
- Consolidate platform icon mapping into shared KnownPlatforms.icon(for:)
- Map execute_code tool to ToolKind.execute
- Add Settings UI for reasoning effort, approval mode, show cost
- Show memory provider warning when external provider (Honcho) is active
- Replace fragile manual HermesSession init with withTitle() helper
- De-duplicate formatTokens utility function
- Bump version to 1.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:46:03 -04:00
Alan Wizemann 303f4502dd Fix version reporting: update MARKETING_VERSION to 1.3.0
All builds were reporting version 1.0 because the Xcode project version
was never updated from its default. Fixes #5, fixes #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:50:57 -04:00
Alan Wizemann 815c9dcbcd Merge pull request #6 from awizemann/code-quality
Code quality improvements and webview dashboard widget
2026-04-02 12:04:16 -04:00
Alan Wizemann ef53ac1c93 Replace webview split layout with tabbed Dashboard/Site interface
Dashboards with a webview widget now show a tab bar: Dashboard tab
renders all normal widgets, Site tab displays the web content
full-canvas with even margins. Cleaner UX than the split layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:03:50 -04:00
Alan Wizemann 2a3e8b1422 Add webview widget for embedded web browser in project dashboards
New widget type that renders any URL (local dev servers, HTML reports)
directly in the dashboard via WKWebView. Sections with webviews
automatically split layout: grid widgets left, webview right.
Configurable height, non-persistent data store, navigation error logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:29:05 -04:00
Alan Wizemann 563f5a702c Improve code quality: error logging, constants, path validation, safe defaults
- Replace try? with do/catch and [Scarf] error logging in all service-layer
  JSON decoding, file writes, and directory creation
- Extract sqliteTransient constant replacing raw unsafeBitCast(-1, ...) pattern
- Add QueryDefaults and FileSizeUnit enums for all magic numbers
- Guard HOME env var with NSHomeDirectory() fallback instead of force-unwrap
- Add path traversal validation to loadSkillContent()
- Add SessionStats.empty and use it across all initialization sites
- Replace KnownPlatforms array indexing with named .cli constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:15:03 -04:00
Alan Wizemann c7f3ca9be3 Merge feature/project-dashboards into main 2026-04-01 01:30:55 -04:00
Alan Wizemann dbaadb8037 Add Project Dashboards feature with agent-generated widgets
Introduces a new Projects section that renders custom dashboards from
JSON files in project directories. Supports 7 widget types (stat,
progress, text, table, chart, list) with live file-watching refresh.
Includes project registry, SwiftUI Charts integration, schema docs,
and comprehensive README documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:48:13 -04:00
Alan Wizemann ce001fe202 Add left padding to terminal view in chat interface
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:28:46 -04:00
Alan Wizemann a329eca419 Merge branch 'development' 2026-03-31 15:30:51 -04:00
Alan Wizemann 528de938c5 Add pre-built binary install instructions to README
Universal binary (arm64 + x86_64) available on Releases page.
Updated Building section to Install with download + build options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:30:50 -04:00
Alan Wizemann 4f791d491e Merge pull request #4 from awizemann/development
Config Editor and Voice Fixes
2026-03-31 14:35:33 -04:00
Alan Wizemann dd79891874 Replace read-only Settings with structured config editor
Settings view now has editable form controls organized by section:

Model: editable model name field, provider dropdown picker
Display: personality picker (parsed from config), streaming/reasoning/verbose toggles
Terminal: backend picker (local/docker/singularity/modal/daytona/ssh), max turns stepper
Voice: auto TTS toggle, silence threshold stepper
Memory: enabled toggle, char limit steppers, nudge interval stepper

All changes write via `hermes config set key value` CLI with save
confirmation feedback. Open in Editor button launches the raw YAML
in the default text editor. Paths and raw config sections retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:23:56 -04:00
Alan Wizemann a13288e759 Merge branch 'main' of https://github.com/awizemann/scarf 2026-03-31 14:07:44 -04:00
Alan Wizemann a16c8ec2d9 Merge branch 'development' 2026-03-31 14:07:17 -04:00
Alan Wizemann 0e3712116f Merge pull request #3 from awizemann/development
Development to Main - New Voice Commands, Health Dashboard, Tools and gateway control
2026-03-31 14:05:40 -04:00
Alan Wizemann ab45f95790 Fix TTS toggle state reversed on voice enable
Hermes auto-enables TTS when voice mode turns on (auto_tts config).
Our ttsEnabled started as false, so the UI showed off when TTS was
actually on. Now reads auto_tts from config.yaml when voice enables
and sets the initial state to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:03:34 -04:00
Alan Wizemann d31bc63b6a Add microphone permission for voice chat
Hermes voice mode needs mic access when running as a Scarf subprocess.
- Added NSMicrophoneUsageDescription to Info.plist keys
- Created entitlements file with com.apple.security.device.audio-input
- Applied to both Debug and Release configurations

macOS will prompt for mic permission on first push-to-talk use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:56:49 -04:00
Alan Wizemann bc8f4b0c25 Add TTS toggle button to voice controls
Voice toolbar now shows three controls when voice is enabled:
- Mic toggle (voice on/off)
- TTS toggle (speaker icon, sends /voice tts)
- Push to Talk (waveform, sends Ctrl+B)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:50:14 -04:00
Alan Wizemann 55ee99c839 Add Hermes version compatibility section to README
Documents tested versions and the interfaces Scarf depends on
(SQLite schema v6, CLI output parsing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:45:50 -04:00
Alan Wizemann 3477fa733f Redesign Health view with card grid and expandable sections
Replaced the long flat list with a cleaner layout:
- Compact header bar: version, update banner, pass/warn/error counts
- Status/Diagnostics tab switcher (segmented control)
- 2-column card grid: each section is a uniform card showing icon,
  title, and colored status dot counts (green/orange/red)
- Cards have a colored border accent based on worst status
- Click to expand: reveals individual check rows inline
- Only one section expanded at a time for clean scanning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:39:42 -04:00
Alan Wizemann c6f45ac22e Add System Health view with status and diagnostics
New Health section in the Manage group combining hermes status and
hermes doctor output:

- Version header with update available banner (e.g. "47 commits behind")
- Summary badges: passing/warning/issue counts
- Status sections: environment, API keys, auth providers, terminal
  backend, messaging platforms, gateway service, scheduled jobs
- Diagnostics sections: Python environment, required/optional packages,
  config files, directory structure, external tools, API connectivity,
  submodules, tool availability, Skills Hub, Honcho memory
- Each check shows green/orange/red icon with label and detail
- Refresh button to re-run both commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:36:56 -04:00
Alan Wizemann b4c93ac79c Add Gateway Control to README features, architecture, and data sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:33:16 -04:00
Alan Wizemann c09f167760 Add Gateway Control Center with service control and pairing management
New Gateway section in the Manage group:
- Service controls: Start/Stop/Restart buttons calling hermes gateway CLI
- Status display: state (running/stopped), PID, loaded indicator, stale
  service warning, exit reason, last update timestamp
- Platform cards: each connected messaging platform with connection state
  (reads from gateway_state.json)
- Pairing management: approved users list with revoke button, pending
  pairing codes with approve button
- Auto-refreshes via HermesFileWatcher when gateway state changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:32:29 -04:00
Alan Wizemann b79200e950 Update README with Tools Manager, session management, and revised docs
- Added Tools Manager to features list
- Updated Sessions Browser with rename/delete/export
- Updated Skills Browser with file switcher
- Updated Dashboard with live refresh
- Updated Log Viewer with text search
- Added hermes tools and hermes sessions to data sources table
- Revised How It Works section to cover management actions
- Updated architecture tree with Tools feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:02:29 -04:00
Alan Wizemann a800a630a8 Fix session rename not updating across views
After rename:
- Update selectedSession so detail header refreshes immediately
- Update sessionPreviews so previewFor() returns the new title
- Dashboard now observes HermesFileWatcher and reloads on DB changes
- Chat session menu reloads via file watcher (persists across nav)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:01:20 -04:00
Alan Wizemann e4d5bb0364 Add session management: rename, delete, export, and stats bar
Sessions browser enhancements:
- Stats bar: total sessions, messages, DB size, per-platform counts
- Right-click context menu on session rows: Rename, Export, Delete
- Detail view actions menu (ellipsis button): same actions
- Rename: sheet with text field, calls hermes sessions rename
- Delete: confirmation dialog, calls hermes sessions delete --yes
- Export single session: NSSavePanel, calls hermes sessions export
- Export all: button in stats bar, exports everything to JSONL
- Session ID shown in detail header for reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:55:35 -04:00
Alan Wizemann 36757a8c9a Add Tool Management panel with per-platform toggle switches
New Tools section in the Manage group:
- Platform tabs parsed from config.yaml (CLI, Telegram, Discord, etc.)
- Lists all toolsets with emoji icon, name, description, and toggle
- Toggle switches call hermes tools enable/disable under the hood
- Shows enabled count vs total
- MCP server status section at bottom
- Optimistic UI update on toggle with CLI fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:47:25 -04:00
Alan Wizemann cfbf3ea142 Update README with Insights, voice controls, and activity filter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:31:50 -04:00
Alan Wizemann f3cb1eb86b Add Insights Dashboard with usage analytics
New sidebar section showing rich analytics from the sessions database:
- Overview grid: sessions, messages, tokens (input/output/cache), active
  time, avg session duration, avg messages per session
- Model breakdown: sessions and total tokens per model
- Platform breakdown: CLI vs Telegram etc with session/message counts
- Top tools bar chart: ranked by call count with percentages
- Activity patterns: day-of-week bars and hourly heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
  with clickable links to open in Sessions browser
- Time period selector: 7/30/90 days or all time

Also adds ROADMAP.md documenting the full feature expansion plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:45:35 -04:00
Alan Wizemann 2b57025f3c Merge pull request #1 from awizemann/development
Buy Me a Coffee (seriously, I am tired).
2026-03-31 03:44:49 -04:00
Alan Wizemann 2a14e28589 Fix Buy Me a Coffee button image URL
Use CDN-hosted default button instead of API-generated image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:27:43 -04:00
Alan Wizemann 39bac7d2be Add Buy Me a Coffee button to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:26:12 -04:00
Alan Wizemann af8e120c9f Remove cost stat card from dashboard
Hermes cost tracking returns $0.00 for models not in its static
pricing table (including claude-haiku-4-5). Token counts remain
displayed since those are always accurate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:12:04 -04:00
Alan Wizemann 0d38856b3e Add session filter to Activity view
Dropdown in the filter bar lets users scope activity to a single
session or view all. Sessions are labeled with their first user
message preview. Combines with the existing tool-kind filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:03:28 -04:00
89 changed files with 10520 additions and 260 deletions
+3
View File
@@ -43,3 +43,6 @@ Package.resolved
# Claude Code
.claude/
scarf/standards/backups/
# Scarf project dashboards (user-specific)
.scarf/
+4
View File
@@ -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.
+214 -21
View File
@@ -10,31 +10,64 @@
</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>
## Features
- **Dashboard** — System health, token usage, cost tracking, recent sessions at a glance
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5)
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm)
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh
- **Skills Browser** — Browse all installed skills by category with file content viewer
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering
- **Settings** — Read-only config display with raw YAML viewer and Finder path links
- **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
- **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 and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
- **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
- **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
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import`
- **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) 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)
## Building
### Compatibility
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
| Hermes Version | Status |
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13, latest) | Verified |
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
## Install
### Pre-built Binary (no Xcode required)
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
1. Unzip and drag **Scarf.app** to Applications
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
```bash
git clone https://github.com/awizemann/scarf.git
@@ -45,7 +78,7 @@ open scarf.xcodeproj
Or from the command line:
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
```
## Architecture
@@ -59,14 +92,19 @@ scarf/
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
Features/ Self-contained feature modules
Dashboard/ System overview and stats
Sessions/ Conversation browser with detail view
Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Chat/ Embedded terminal via SwiftTerm
Projects/ Agent-generated project dashboards with widget rendering
Chat/ Rich ACP chat and embedded terminal with voice controls
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
Settings/ Configuration display
Settings/ Structured config editor
Navigation/ AppCoordinator + SidebarView
```
@@ -83,9 +121,18 @@ 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 |
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes.
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
### Dependencies
@@ -93,14 +140,154 @@ The app **never writes** to `state.db` — it opens in read-only mode to avoid W
|---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching.
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
## How It Works
Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering.
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build
- **Development dashboards** — test coverage, build status, open issues, sprint progress
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
- **Research dashboards** — experiment results, key findings, paper status checklists
- **Agent activity views** — cron job results, content generation stats, task completion rates
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
- **Any project status** — if your agent can measure it, Scarf can display it
### Quick Start
**1. Create the dashboard file**
Create `.scarf/dashboard.json` in any project folder:
```json
{
"version": 1,
"title": "My Project",
"description": "Project status at a glance",
"sections": [
{
"title": "Overview",
"columns": 3,
"widgets": [
{
"type": "stat",
"title": "Test Coverage",
"value": "87%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% this week"
},
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
},
{
"type": "list",
"title": "Tasks",
"items": [
{ "text": "Write unit tests", "status": "done" },
{ "text": "Update API docs", "status": "active" },
{ "text": "Deploy to prod", "status": "pending" }
]
}
]
}
]
}
```
**2. Register your project**
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
]
}
```
**3. View in Scarf**
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
### Widget Types
| Type | Description | Key Fields |
|------|-------------|------------|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
| `progress` | Progress bar with label | `value` (0.01.0), `label`, `color` |
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
| `table` | Data table with headers | `columns`, `rows` |
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
| `webview` | Embedded web browser | `url`, `height` (default 400) |
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
```json
{
"type": "webview",
"title": "Project Report",
"url": "http://localhost:8000/dashboard",
"height": 500
}
```
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
### Agent-Generated Dashboards
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
### Dashboard Schema Reference
```json
{
"version": 1,
"title": "Required — dashboard title",
"description": "Optional — subtitle text",
"updatedAt": "Optional — ISO 8601 timestamp",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": [{ "type": "...", "title": "..." }]
}
]
}
```
Each section defines a grid with 14 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.
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
@@ -111,6 +298,12 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
## Support
If you find Scarf useful, consider buying me a coffee.
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="40"></a>
## License
[MIT](LICENSE)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+169
View File
@@ -0,0 +1,169 @@
# Scarf Project Dashboard Schema
Scarf can render project dashboards from a JSON file. Place a `dashboard.json` file at `.scarf/dashboard.json` in your project root, and register the project in Scarf.
## Registration
Projects are registered in `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/path/to/my-project" }
]
}
```
You can also add projects from the Scarf UI via the Projects section.
## Dashboard File
Create `.scarf/dashboard.json` in your project root:
```json
{
"version": 1,
"title": "My Project",
"description": "Optional description",
"updatedAt": "2026-03-31T14:00:00Z",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": []
}
]
}
```
## Widget Types
### stat — Key metric display
```json
{
"type": "stat",
"title": "Test Coverage",
"value": "87.3%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% from last week"
}
```
- `value`: String or number
- `icon`: SF Symbol name (optional)
- `color`: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray (optional)
- `subtitle`: Secondary text (optional)
### progress — Progress bar
```json
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
}
```
- `value`: Number between 0.0 and 1.0
- `label`: Text below the bar (optional)
- `color`: Named color (optional)
### text — Rich text block
```json
{
"type": "text",
"title": "Release Notes",
"content": "**v2.4.1** — Fixed auth timeout\n\n- Bug fix for session handling",
"format": "markdown"
}
```
- `content`: Text content
- `format`: "markdown" or "plain" (default: plain)
### table — Data table
```json
{
"type": "table",
"title": "Recent Deploys",
"columns": ["Date", "Env", "Status"],
"rows": [
["Mar 30", "prod", "success"],
["Mar 29", "staging", "success"]
]
}
```
### chart — Line, bar, or pie chart
```json
{
"type": "chart",
"title": "Tests Over Time",
"chartType": "line",
"series": [
{
"name": "Passing",
"color": "green",
"data": [
{ "x": "Mon", "y": 142 },
{ "x": "Tue", "y": 145 }
]
}
]
}
```
- `chartType`: "line", "bar", or "pie"
- `series[].color`: Named color (optional)
- For pie charts, each series becomes a slice
### list — Checklist
```json
{
"type": "list",
"title": "TODO Items",
"icon": "checklist",
"items": [
{ "text": "Write tests", "status": "done" },
{ "text": "Update docs", "status": "active" },
{ "text": "Deploy", "status": "pending" }
]
}
```
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
### webview — Embedded web browser
```json
{
"type": "webview",
"title": "Project Dashboard",
"url": "http://localhost:8000",
"height": 500
}
```
- `url`: Any URL — local servers, file paths, or remote pages
- `height`: Height in points (optional, default: 400)
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
## Agent Instructions
To have your Hermes agent generate a dashboard, include these instructions:
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
> in `~/.hermes/scarf/projects.json` if not already registered.
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
+58
View File
@@ -0,0 +1,58 @@
# Scarf — Feature Roadmap
## Tier 1 — High Value, Data Already Available
### 1. Insights Dashboard
Rich usage analytics pulled from the sessions and messages SQLite tables:
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
- Model breakdown: sessions and tokens per model
- Platform breakdown: CLI vs Telegram vs Discord usage
- Top tools chart: ranked tool usage with call counts and percentages
- Activity patterns: sessions by day-of-week, peak hours heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
- Time period selector: last 7/30/90 days
### 2. Tool Management Panel
- List all toolsets with enabled/disabled status and descriptions
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
- Per-platform tool configuration
- MCP tool status
### 3. Session Management Enhancements
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
- Delete sessions (via `hermes sessions delete`)
- Export sessions to JSONL (via `hermes sessions export`)
- Session stats card (total count, DB size, per-platform breakdown)
## Tier 2 — Medium Value, New Service Code Required
### 4. Skills Hub
- Search remote registries for new skills (6 sources)
- Install/uninstall skills from GUI
- Skill update indicator
- Trust level badges (builtin, local, hub)
### 5. Gateway Control Center
- Start/stop/restart gateway from GUI
- Real-time status: PID, uptime, connected platforms
- Pairing management: view approved users, approve/revoke
- Platform status per messaging service
### 6. System Health View
- Mirror `hermes status` and `hermes doctor` output
- API key validation, auth provider status, external tools
- Update available indicator
## Tier 3 — Nice to Have
### 7. Profile Management
- List/create/switch profiles (isolated Hermes instances)
### 8. Plugin Management
- Install from Git, enable/disable, update
### 9. MCP Server Management
- Add/remove/test MCP servers, toggle tools per server
### 10. Config Editor
- Structured form editor for config.yaml with validation
+18 -12
View File
@@ -404,9 +404,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -415,11 +416,13 @@
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.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -438,9 +441,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
@@ -449,11 +453,13 @@
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.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.5.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -471,11 +477,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -492,11 +498,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -512,10 +518,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -531,10 +537,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
+12
View File
@@ -16,18 +16,30 @@ struct ContentView: 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 .mcpServers:
MCPServersView()
case .gateway:
GatewayView()
case .cron:
CronView()
case .health:
HealthView()
case .logs:
LogsView()
case .settings:
+246
View File
@@ -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 ""
}
}
+33 -1
View File
@@ -13,6 +13,22 @@ struct HermesConfig: Sendable {
var streaming: Bool
var showReasoning: Bool
var verbose: Bool
var autoTTS: Bool
var silenceThreshold: Int
var reasoningEffort: String
var showCost: Bool
var approvalMode: String
var browserBackend: String
var memoryProvider: String
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
var serviceTier: String
var gatewayNotifyInterval: Int
var forceIPv4: Bool
var contextEngine: String
var interimAssistantMessages: Bool
var honchoInitOnSessionStart: Bool
static let empty = HermesConfig(
model: "unknown",
@@ -26,7 +42,23 @@ struct HermesConfig: Sendable {
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false
verbose: false,
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false
)
}
+35 -3
View File
@@ -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,6 +17,35 @@ 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 hermesBinary: String = userHome + "/.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"
}
// 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
}
+27 -1
View File
@@ -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
}
+3 -1
View File
@@ -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
+26 -9
View File
@@ -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]
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
struct HermesToolset: Identifiable, Sendable {
var id: String { name }
let name: String
let description: String
let icon: String
var enabled: Bool
}
struct HermesToolPlatform: Identifiable, Sendable {
var id: String { name }
let name: String
let displayName: String
let icon: String
}
enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [
cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
}
@@ -0,0 +1,174 @@
import Foundation
struct MCPServerPreset: Identifiable, Sendable, Equatable {
let id: String
let displayName: String
let description: String
let category: String
let iconSystemName: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let requiredEnvKeys: [String]
let optionalEnvKeys: [String]
let pathArgPrompt: String?
let docsURL: String
static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
description: "Read and write files under a root directory you choose.",
category: "Built-in",
iconSystemName: "folder",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Root directory (absolute path)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
),
MCPServerPreset(
id: "github",
displayName: "GitHub",
description: "Issues, pull requests, code search, and file operations via GitHub API.",
category: "Dev",
iconSystemName: "chevron.left.forwardslash.chevron.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil,
auth: nil,
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
),
MCPServerPreset(
id: "postgres",
displayName: "Postgres",
description: "Read-only SQL access against a Postgres database.",
category: "Data",
iconSystemName: "cylinder.split.1x2",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
),
MCPServerPreset(
id: "slack",
displayName: "Slack",
description: "Read channels, post messages, and search your Slack workspace.",
category: "Productivity",
iconSystemName: "bubble.left.and.bubble.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-slack"],
url: nil,
auth: nil,
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
),
MCPServerPreset(
id: "linear",
displayName: "Linear",
description: "Query and update Linear issues. Uses OAuth — no token needed.",
category: "Productivity",
iconSystemName: "list.bullet.rectangle",
transport: .http,
command: nil,
args: [],
url: "https://mcp.linear.app/sse",
auth: "oauth",
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://linear.app/docs/mcp"
),
MCPServerPreset(
id: "sentry",
displayName: "Sentry",
description: "Investigate errors and performance issues from Sentry.",
category: "Dev",
iconSystemName: "exclamationmark.triangle",
transport: .stdio,
command: "npx",
args: ["-y", "@sentry/mcp-server"],
url: nil,
auth: nil,
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://docs.sentry.io/product/mcp/"
),
MCPServerPreset(
id: "puppeteer",
displayName: "Puppeteer",
description: "Headless browser automation — navigate pages, click, screenshot.",
category: "Automation",
iconSystemName: "safari",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
),
MCPServerPreset(
id: "memory",
displayName: "Memory (Knowledge Graph)",
description: "Persistent knowledge graph of entities and relations across sessions.",
category: "Built-in",
iconSystemName: "brain",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: ["MEMORY_FILE_PATH"],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
),
MCPServerPreset(
id: "fetch",
displayName: "Fetch",
description: "Retrieve and convert web pages to markdown.",
category: "Built-in",
iconSystemName: "arrow.down.circle",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
)
]
static var categories: [String] {
var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
}
static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
@@ -0,0 +1,138 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
+516
View File
@@ -0,0 +1,516 @@
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 = ""
/// 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
var env = ProcessInfo.processInfo.environment
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
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))")
}
}
}
}
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"
}
}
}
+320 -50
View File
@@ -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,40 +302,134 @@ 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 fetchUserMessageCount(since: Date) -> Int {
guard let db else { return 0 }
let sql = """
SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int(stmt, 0))
}
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
guard let db else { return [] }
let sql = """
SELECT m.tool_name, COUNT(*) as cnt
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
GROUP BY m.tool_name
ORDER BY cnt DESC
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var results: [(name: String, count: Int)] = []
while sqlite3_step(stmt) == SQLITE_ROW {
let name = columnText(stmt!, 0)
let count = Int(sqlite3_column_int(stmt!, 1))
results.append((name: name, count: count))
}
return results
}
func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var hours: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let hour = calendar.component(.hour, from: date)
hours[hour, default: 0] += 1
}
return hours
}
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var days: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
days[weekday, default: 0] += 1
}
return days
}
func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal"
let dbPath = HermesPaths.stateDB
@@ -214,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
)
}
@@ -231,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 {
@@ -259,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: " ")
}
}
+810 -17
View File
@@ -12,12 +12,37 @@ struct HermesFileService: Sendable {
private func parseConfig(_ yaml: String) -> HermesConfig {
var values: [String: String] = [:]
var currentSection = ""
var dockerEnv: [String: String] = [:]
var commandAllowlist: [String] = []
var inDockerEnv = false
var inAllowlist = false
for line in yaml.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
// Detect end of nested blocks when indent returns to section level
if indent <= 2 && (inDockerEnv || inAllowlist) {
inDockerEnv = false
inAllowlist = false
}
// Collect docker_env nested key-value pairs
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
dockerEnv[key] = val
continue
}
// Collect allowlist items
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
commandAllowlist.append(String(trimmed.dropFirst(2)))
continue
}
if indent == 0 && trimmed.hasSuffix(":") {
currentSection = String(trimmed.dropLast())
continue
@@ -26,6 +51,16 @@ struct HermesFileService: Sendable {
if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
if key == "docker_env" && val.isEmpty {
inDockerEnv = true
continue
}
if key == "permanent_allowlist" && val.isEmpty {
inAllowlist = true
continue
}
values[currentSection + "." + key] = val
}
}
@@ -42,7 +77,23 @@ struct HermesFileService: Sendable {
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
streaming: values["display.streaming"] != "false",
showReasoning: values["display.show_reasoning"] == "true",
verbose: values["agent.verbose"] == "true"
verbose: values["agent.verbose"] == "true",
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
showCost: values["display.show_cost"] == "true",
approvalMode: values["approvals.mode"] ?? "manual",
browserBackend: values["browser.backend"] ?? "",
memoryProvider: values["memory.provider"] ?? "",
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: values["memory.profile"] ?? "",
serviceTier: values["agent.service_tier"] ?? "normal",
gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600,
forceIPv4: values["network.force_ipv4"] == "true",
contextEngine: values["context.engine"] ?? "compressor",
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true"
)
}
@@ -50,33 +101,64 @@ struct HermesFileService: Sendable {
func loadGatewayState() -> GatewayState? {
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
return try? JSONDecoder().decode(GatewayState.self, from: data)
do {
return try JSONDecoder().decode(GatewayState.self, from: data)
} catch {
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
return nil
}
}
// MARK: - Memory
func loadMemory() -> String {
readFile(HermesPaths.memoryMD) ?? ""
func loadMemoryProfiles() -> [String] {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
return entries.filter { name in
var isDir: ObjCBool = false
let path = HermesPaths.memoriesDir + "/" + name
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
}.sorted()
}
func loadUserProfile() -> String {
readFile(HermesPaths.userMD) ?? ""
func loadMemory(profile: String = "") -> String {
let path = memoryPath(profile: profile, file: "MEMORY.md")
return readFile(path) ?? ""
}
func saveMemory(_ content: String) {
writeFile(HermesPaths.memoryMD, content: content)
func loadUserProfile(profile: String = "") -> String {
let path = memoryPath(profile: profile, file: "USER.md")
return readFile(path) ?? ""
}
func saveUserProfile(_ content: String) {
writeFile(HermesPaths.userMD, content: content)
func saveMemory(_ content: String, profile: String = "") {
let path = memoryPath(profile: profile, file: "MEMORY.md")
writeFile(path, content: content)
}
func saveUserProfile(_ content: String, profile: String = "") {
let path = memoryPath(profile: profile, file: "USER.md")
writeFile(path, content: content)
}
private func memoryPath(profile: String, file: String) -> String {
if profile.isEmpty {
return HermesPaths.memoriesDir + "/" + file
}
return HermesPaths.memoriesDir + "/" + profile + "/" + file
}
// MARK: - Cron
func loadCronJobs() -> [HermesCronJob] {
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
return file?.jobs ?? []
do {
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
}
}
func loadCronOutput(jobId: String) -> String? {
@@ -106,12 +188,14 @@ struct HermesFileService: Sendable {
var isSkillDir: ObjCBool = false
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files.sorted()
files: files.sorted(),
requiredConfig: requiredConfig
)
}
@@ -121,12 +205,653 @@ struct HermesFileService: Sendable {
}
func loadSkillContent(path: String) -> String {
readFile(path) ?? ""
guard isValidSkillPath(path) else { return "" }
return readFile(path) ?? ""
}
func saveSkillContent(path: String, content: String) {
guard isValidSkillPath(path) else { return }
writeFile(path, content: content)
}
private func isValidSkillPath(_ path: String) -> Bool {
guard !path.contains(".."), path.hasPrefix(HermesPaths.skillsDir) else {
print("[Scarf] Rejected skill path outside skills directory: \(path)")
return false
}
return true
}
private func parseSkillRequiredConfig(_ path: String) -> [String] {
guard let content = readFile(path) else { return [] }
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
// MARK: - MCP Servers
func loadMCPServers() -> [HermesMCPServer] {
guard let yaml = readFile(HermesPaths.configYAML) else { return [] }
let parsed = parseMCPServersBlock(yaml: yaml)
let fm = FileManager.default
return parsed.map { server in
let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json"
let hasToken = fm.fileExists(atPath: tokenPath)
guard hasToken != server.hasOAuthToken else { return server }
return HermesMCPServer(
name: server.name,
transport: server.transport,
command: server.command,
args: server.args,
url: server.url,
auth: server.auth,
env: server.env,
headers: server.headers,
timeout: server.timeout,
connectTimeout: server.connectTimeout,
enabled: server.enabled,
toolsInclude: server.toolsInclude,
toolsExclude: server.toolsExclude,
resourcesEnabled: server.resourcesEnabled,
promptsEnabled: server.promptsEnabled,
hasOAuthToken: hasToken
)
}
}
/// Creates the server entry via `hermes mcp add` with only the command (no args).
/// Args are written separately via `setMCPServerArgs` to avoid argparse issues with `-`-prefixed args like `-y`.
/// Pipes `y\n` because the CLI prompts to save even when the initial connection check fails (which it will, since we intentionally add no args first).
@discardableResult
func addMCPServerStdio(name: String, command: String, args: [String]) -> (exitCode: Int32, output: String) {
let addResult = runHermesCLI(
args: ["mcp", "add", name, "--command", command],
timeout: 45,
stdinInput: "y\ny\ny\n"
)
guard addResult.exitCode == 0 else { return addResult }
if !args.isEmpty {
_ = setMCPServerArgs(name: name, args: args)
}
return addResult
}
@discardableResult
func addMCPServerHTTP(name: String, url: String, auth: String?) -> (exitCode: Int32, output: String) {
var cliArgs: [String] = ["mcp", "add", name, "--url", url]
if let auth, !auth.isEmpty {
cliArgs.append(contentsOf: ["--auth", auth])
}
return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n")
}
@discardableResult
func setMCPServerArgs(name: String, args: [String]) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertList(header: "args", items: args, in: &entryLines)
}
}
@discardableResult
func removeMCPServer(name: String) -> (exitCode: Int32, output: String) {
runHermesCLI(args: ["mcp", "remove", name], timeout: 30)
}
nonisolated func testMCPServer(name: String) async -> MCPTestResult {
let started = Date()
let service = self
let result = await Task.detached { () -> (Int32, String) in
service.runHermesCLI(args: ["mcp", "test", name], timeout: 30)
}.value
let elapsed = Date().timeIntervalSince(started)
let tools = Self.parseToolListFromTestOutput(result.1)
return MCPTestResult(
serverName: name,
succeeded: result.0 == 0,
output: result.1,
tools: tools,
elapsed: elapsed
)
}
private static func parseToolListFromTestOutput(_ output: String) -> [String] {
var tools: [String] = []
for rawLine in output.components(separatedBy: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("- ") || line.hasPrefix("* ") else { continue }
let candidate = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
// Take only the identifier before any separator (":" or whitespace).
let token = candidate.split(whereSeparator: { ":(".contains($0) || $0.isWhitespace }).first.map(String.init) ?? candidate
if !token.isEmpty, token.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
tools.append(token)
}
}
return tools
}
@discardableResult
func toggleMCPServerEnabled(name: String, enabled: Bool) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertScalar(key: "enabled", value: enabled ? "true" : "false", in: &entryLines)
}
}
@discardableResult
func setMCPServerEnv(name: String, env: [String: String]) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertSubMap(header: "env", map: env, in: &entryLines)
}
}
@discardableResult
func setMCPServerHeaders(name: String, headers: [String: String]) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertSubMap(header: "headers", map: headers, in: &entryLines)
}
}
@discardableResult
func updateMCPToolFilters(name: String, include: [String], exclude: [String], resources: Bool, prompts: Bool) -> Bool {
patchMCPServerField(name: name) { entryLines in
Self.replaceOrInsertToolsBlock(include: include, exclude: exclude, resources: resources, prompts: prompts, in: &entryLines)
}
}
@discardableResult
func setMCPServerTimeouts(name: String, timeout: Int?, connectTimeout: Int?) -> Bool {
patchMCPServerField(name: name) { entryLines in
if let timeout {
Self.replaceOrInsertScalar(key: "timeout", value: String(timeout), in: &entryLines)
} else {
Self.removeScalar(key: "timeout", in: &entryLines)
}
if let connectTimeout {
Self.replaceOrInsertScalar(key: "connect_timeout", value: String(connectTimeout), in: &entryLines)
} else {
Self.removeScalar(key: "connect_timeout", in: &entryLines)
}
}
}
@discardableResult
func deleteMCPOAuthToken(name: String) -> Bool {
let path = HermesPaths.mcpTokensDir + "/" + name + ".json"
do {
try FileManager.default.removeItem(atPath: path)
return true
} catch {
return false
}
}
@discardableResult
func restartGateway() -> (exitCode: Int32, output: String) {
runHermesCLI(args: ["gateway", "restart"], timeout: 30)
}
// MARK: - MCP YAML: block extractor + parser
private struct MCPBlockLocation {
let prefix: [String]
let block: [String] // includes the "mcp_servers:" header line
let suffix: [String]
}
private func extractMCPBlock(yaml: String) -> MCPBlockLocation {
let lines = yaml.components(separatedBy: "\n")
var blockStart = -1
var blockEnd = lines.count
for (index, line) in lines.enumerated() {
if blockStart < 0 {
if line.hasPrefix("mcp_servers:") {
blockStart = index
}
continue
}
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if indent == 0 && trimmed.contains(":") {
blockEnd = index
break
}
}
if blockStart < 0 {
return MCPBlockLocation(prefix: lines, block: [], suffix: [])
}
return MCPBlockLocation(
prefix: Array(lines[0..<blockStart]),
block: Array(lines[blockStart..<blockEnd]),
suffix: Array(lines[blockEnd..<lines.count])
)
}
fileprivate func parseMCPServersBlock(yaml: String) -> [HermesMCPServer] {
let location = extractMCPBlock(yaml: yaml)
guard location.block.count > 1 else { return [] }
var servers: [HermesMCPServer] = []
var currentName: String?
var fields: [String: String] = [:]
var argsList: [String] = []
var envMap: [String: String] = [:]
var headersMap: [String: String] = [:]
var includeList: [String] = []
var excludeList: [String] = []
var resources = false
var prompts = false
var subSection: String?
func flush() {
guard let name = currentName else { return }
let transport: MCPTransport = fields["url"] != nil ? .http : .stdio
let enabledStr = fields["enabled"]?.lowercased()
let enabled = enabledStr != "false"
let timeout = fields["timeout"].flatMap(Int.init)
let connectTimeout = fields["connect_timeout"].flatMap(Int.init)
let server = HermesMCPServer(
name: name,
transport: transport,
command: fields["command"].map { Self.unquote($0) },
args: argsList,
url: fields["url"].map { Self.unquote($0) },
auth: fields["auth"].map { Self.unquote($0) },
env: envMap,
headers: headersMap,
timeout: timeout,
connectTimeout: connectTimeout,
enabled: enabled,
toolsInclude: includeList,
toolsExclude: excludeList,
resourcesEnabled: resources,
promptsEnabled: prompts,
hasOAuthToken: false
)
servers.append(server)
currentName = nil
fields = [:]
argsList = []
envMap = [:]
headersMap = [:]
includeList = []
excludeList = []
resources = false
prompts = false
subSection = nil
}
for rawLine in location.block.dropFirst() {
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = rawLine.prefix(while: { $0 == " " }).count
if indent == 2 && trimmed.hasSuffix(":") && !trimmed.contains(" ") {
flush()
currentName = String(trimmed.dropLast())
subSection = nil
continue
}
guard currentName != nil else { continue }
if indent == 4 {
if trimmed.hasPrefix("- ") && subSection == "args" {
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
continue
}
subSection = nil
if trimmed.hasSuffix(":") {
subSection = String(trimmed.dropLast())
continue
}
if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
fields[key] = value
}
continue
}
if indent >= 6 {
switch subSection {
case "args":
if trimmed.hasPrefix("- ") {
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
}
case "env":
if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
envMap[key] = Self.unquote(value)
}
case "headers":
if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
headersMap[key] = Self.unquote(value)
}
case "tools":
if trimmed == "include:" {
subSection = "tools.include"
} else if trimmed == "exclude:" {
subSection = "tools.exclude"
} else if trimmed.hasPrefix("resources:") {
resources = trimmed.lowercased().hasSuffix("true")
} else if trimmed.hasPrefix("prompts:") {
prompts = trimmed.lowercased().hasSuffix("true")
}
case "tools.include":
if trimmed.hasPrefix("- ") {
includeList.append(Self.unquote(String(trimmed.dropFirst(2))))
}
case "tools.exclude":
if trimmed.hasPrefix("- ") {
excludeList.append(Self.unquote(String(trimmed.dropFirst(2))))
}
default:
break
}
}
}
flush()
return servers
}
// MARK: - MCP YAML: surgical patcher
private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool {
guard let yaml = readFile(HermesPaths.configYAML) else { return false }
let location = extractMCPBlock(yaml: yaml)
guard !location.block.isEmpty else { return false }
var block = location.block
var entryStart = -1
var entryEnd = block.count
for (index, line) in block.enumerated() {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let indent = line.prefix(while: { $0 == " " }).count
if entryStart < 0 {
if indent == 2 && trimmed == "\(name):" {
entryStart = index
}
continue
}
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if indent <= 2 {
entryEnd = index
break
}
}
guard entryStart >= 0 else { return false }
var entryLines = Array(block[entryStart..<entryEnd])
mutate(&entryLines)
block.replaceSubrange(entryStart..<entryEnd, with: entryLines)
var combined: [String] = []
combined.append(contentsOf: location.prefix)
combined.append(contentsOf: block)
combined.append(contentsOf: location.suffix)
let newYAML = combined.joined(separator: "\n")
writeFile(HermesPaths.configYAML, content: newYAML)
return true
}
// MARK: - MCP YAML: mutators
private static func replaceOrInsertScalar(key: String, value: String, in lines: inout [String]) {
// entry header is at lines[0] at indent 2. Scalars live at indent 4.
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
lines[index] = " \(key): \(value)"
return
}
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
break
}
}
// Insert right after header.
lines.insert(" \(key): \(value)", at: 1)
}
private static func removeScalar(key: String, in lines: inout [String]) {
var removeIndex: Int?
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
removeIndex = index
break
}
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
break
}
}
if let removeIndex {
lines.remove(at: removeIndex)
}
}
private static func replaceOrInsertList(header: String, items: [String], in lines: inout [String]) {
var headerIndex: Int?
var removeEnd: Int?
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent == 4 && trimmed == "\(header):" {
headerIndex = index
continue
}
if headerIndex != nil {
// List items can appear at indent 4 (as " - item") OR indent 6 depending on style.
if trimmed.hasPrefix("- ") && indent >= 4 {
continue
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
} else if indent >= 6 {
continue
} else {
removeEnd = index
break
}
}
}
if items.isEmpty {
if let headerIndex, let end = removeEnd {
lines.removeSubrange(headerIndex..<end)
} else if let headerIndex {
lines.removeSubrange(headerIndex..<lines.count)
}
return
}
var newLines: [String] = [" \(header):"]
for item in items {
newLines.append(" - \(yamlScalar(item))")
}
if let headerIndex {
let end = removeEnd ?? lines.count
lines.replaceSubrange(headerIndex..<end, with: newLines)
} else {
var insertAt = lines.count
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
insertAt = index
break
}
}
lines.insert(contentsOf: newLines, at: insertAt)
}
}
private static func replaceOrInsertSubMap(header: String, map: [String: String], in lines: inout [String]) {
var headerIndex: Int?
var removeEnd: Int?
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent == 4 && trimmed == "\(header):" {
headerIndex = index
continue
}
if headerIndex != nil {
if indent >= 6 {
continue
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
} else {
removeEnd = index
break
}
}
}
var newLines: [String] = []
if map.isEmpty {
if let headerIndex, let end = removeEnd {
lines.removeSubrange(headerIndex..<end)
} else if let headerIndex {
lines.removeSubrange(headerIndex..<lines.count)
}
return
}
newLines.append(" \(header):")
for key in map.keys.sorted() {
let value = map[key] ?? ""
newLines.append(" \(key): \(yamlScalar(value))")
}
if let headerIndex {
let end = removeEnd ?? lines.count
lines.replaceSubrange(headerIndex..<end, with: newLines)
} else {
// Insert just before the first indent<=2 line we find after the header, else at end.
var insertAt = lines.count
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
insertAt = index
break
}
}
lines.insert(contentsOf: newLines, at: insertAt)
}
}
private static func replaceOrInsertToolsBlock(include: [String], exclude: [String], resources: Bool, prompts: Bool, in lines: inout [String]) {
var headerIndex: Int?
var removeEnd: Int?
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent == 4 && trimmed == "tools:" {
headerIndex = index
continue
}
if headerIndex != nil {
if indent >= 6 {
continue
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
} else {
removeEnd = index
break
}
}
}
var newLines: [String] = [" tools:"]
newLines.append(" include:")
for tool in include { newLines.append(" - \(yamlScalar(tool))") }
newLines.append(" exclude:")
for tool in exclude { newLines.append(" - \(yamlScalar(tool))") }
newLines.append(" resources: \(resources ? "true" : "false")")
newLines.append(" prompts: \(prompts ? "true" : "false")")
if let headerIndex {
let end = removeEnd ?? lines.count
lines.replaceSubrange(headerIndex..<end, with: newLines)
} else {
var insertAt = lines.count
for index in 1..<lines.count {
let line = lines[index]
let indent = line.prefix(while: { $0 == " " }).count
let trimmed = line.trimmingCharacters(in: .whitespaces)
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
insertAt = index
break
}
}
lines.insert(contentsOf: newLines, at: insertAt)
}
}
private static func yamlScalar(_ value: String) -> String {
if value.isEmpty { return "\"\"" }
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
if needsQuoting {
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
return value
}
private static func unquote(_ value: String) -> String {
var v = value
if (v.hasPrefix("\"") && v.hasSuffix("\"") && v.count >= 2) || (v.hasPrefix("'") && v.hasSuffix("'") && v.count >= 2) {
v = String(v.dropFirst().dropLast())
}
return v
}
// MARK: - Hermes Process
func isHermesRunning() -> Bool {
hermesPID() != nil
}
func hermesPID() -> pid_t? {
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
@@ -137,9 +862,73 @@ struct HermesFileService: Sendable {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return !data.isEmpty
let output = String(data: data, encoding: .utf8) ?? ""
guard let firstLine = output.components(separatedBy: "\n").first(where: { !$0.isEmpty }),
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
return pid
} catch {
return false
return nil
}
}
@discardableResult
func stopHermes() -> Bool {
// v0.9.0 fixed `hermes gateway stop` so it issues `launchctl bootout` and
// waits for exit. Use the CLI to avoid racing launchd's KeepAlive respawn.
if runHermesCLI(args: ["gateway", "stop"]).exitCode == 0 {
return true
}
guard let pid = hermesPID() else { return false }
return kill(pid, SIGTERM) == 0
}
nonisolated func hermesBinaryPath() -> String? {
let candidates = [
("\(NSHomeDirectory())/.local/bin/hermes"),
"/opt/homebrew/bin/hermes",
"/usr/local/bin/hermes"
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
}
@discardableResult
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
guard let binary = hermesBinaryPath() else { return (-1, "") }
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = args
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let stdinPipe { process.standardInput = stdinPipe }
defer {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
try? stdinPipe?.fileHandleForReading.close()
try? stdinPipe?.fileHandleForWriting.close()
}
do {
try process.run()
if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
try? stdinPipe.fileHandleForWriting.close()
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning { process.terminate() }
process.waitUntilExit()
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "")
return (process.terminationStatus, combined)
} catch {
return (-1, error.localizedDescription)
}
}
@@ -154,6 +943,10 @@ struct HermesFileService: Sendable {
}
private func writeFile(_ path: String, content: String) {
try? content.write(toFile: path, atomically: true, encoding: .utf8)
do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
} catch {
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
}
}
}
@@ -3,7 +3,8 @@ import Foundation
@Observable
final class HermesFileWatcher {
private(set) var lastChangeDate = Date()
private var sources: [DispatchSourceFileSystemObject] = []
private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer?
func startWatching() {
@@ -15,12 +16,17 @@ final class HermesFileWatcher {
HermesPaths.userMD,
HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON,
HermesPaths.agentLog,
HermesPaths.errorsLog,
HermesPaths.gatewayLog
HermesPaths.gatewayLog,
HermesPaths.projectsRegistry,
HermesPaths.mcpTokensDir
]
for path in paths {
watchFile(path)
if let source = makeSource(for: path) {
coreSources.append(source)
}
}
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
@@ -29,17 +35,30 @@ final class HermesFileWatcher {
}
func stopWatching() {
for source in sources {
for source in coreSources + projectSources {
source.cancel()
}
sources.removeAll()
coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate()
timer = nil
}
private func watchFile(_ path: String) {
func updateProjectWatches(_ dashboardPaths: [String]) {
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return }
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
@@ -53,7 +72,7 @@ final class HermesFileWatcher {
Darwin.close(fd)
}
source.resume()
sources.append(source)
return source
}
deinit {
@@ -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,63 @@
import Foundation
struct ProjectDashboardService: Sendable {
// MARK: - Registry
func loadRegistry() -> ProjectRegistry {
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
return 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) {
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)
if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
} else {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
}
}
// MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
return nil
}
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 {
FileManager.default.fileExists(atPath: project.dashboardPath)
}
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
return nil
}
return attrs[.modificationDate] as? Date
}
}
@@ -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)
}
}
@@ -6,9 +6,21 @@ final class ActivityViewModel {
var toolMessages: [HermesMessage] = []
var filterKind: ToolKind?
var filterSessionId: String?
var selectedEntry: ActivityEntry?
var toolResult: String?
var sessionPreviews: [String: String] = [:]
var isLoading = true
var availableSessions: [(id: String, label: String)] {
var seen = Set<String>()
return toolMessages.compactMap { message in
guard seen.insert(message.sessionId).inserted else { return nil }
let label = sessionPreviews[message.sessionId] ?? message.sessionId
return (id: message.sessionId, label: label)
}
}
var filteredActivity: [ActivityEntry] {
let entries = toolMessages.flatMap { message in
message.toolCalls.map { call in
@@ -24,10 +36,11 @@ final class ActivityViewModel {
)
}
}
if let filterKind {
return entries.filter { $0.kind == filterKind }
return entries.filter { entry in
let kindOk = filterKind == nil || entry.kind == filterKind
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
return kindOk && sessionOk
}
return entries
}
func load() async {
@@ -38,9 +51,19 @@ final class ActivityViewModel {
return
}
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
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()
}
@@ -21,31 +21,44 @@ struct ActivityView: View {
}
private var filterBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
viewModel.filterKind = nil
}
ForEach(ToolKind.allCases, id: \.rawValue) { kind in
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
viewModel.filterKind = kind
HStack(spacing: 12) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
viewModel.filterKind = nil
}
ForEach(ToolKind.allCases, id: \.rawValue) { kind in
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
viewModel.filterKind = kind
}
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
Divider()
.frame(height: 16)
Picker(selection: $viewModel.filterSessionId) {
Text("All Sessions").tag(String?.none)
Divider()
ForEach(viewModel.availableSessions, id: \.id) { session in
Text(session.label)
.lineLimit(1)
.tag(String?.some(session.id))
}
} label: {
EmptyView()
}
.frame(maxWidth: 250)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private var activityList: some 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
@@ -130,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,33 +1,441 @@
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()
var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var terminalView: LocalProcessTerminalView?
var hasActiveProcess = false
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?
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)
}
// MARK: - Session Lifecycle
func startNewSession() {
launchTerminal(arguments: ["chat"])
voiceEnabled = false
ttsEnabled = false
isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil)
} else {
launchTerminal(arguments: ["chat"])
}
}
func resumeSession(_ sessionId: String) {
launchTerminal(arguments: ["chat", "--resume", sessionId])
voiceEnabled = false
ttsEnabled = false
isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: sessionId)
} else {
richChatViewModel.setSessionId(sessionId)
launchTerminal(arguments: ["chat", "--resume", sessionId])
}
}
func continueLastSession() {
launchTerminal(arguments: ["chat", "--continue"])
voiceEnabled = false
ttsEnabled = false
isRecording = false
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 {
let msg = error.localizedDescription
logger.error("Auto-start ACP failed: \(msg)")
acpStatus = "Failed"
acpError = msg
hasActiveProcess = false
acpClient = nil
}
}
}
private func sendViaACP(client: ACPClient, text: String) {
guard let sessionId = richChatViewModel.sessionId else {
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 {
let msg = error.localizedDescription
logger.error("ACP prompt failed: \(msg)")
acpStatus = "Error"
acpError = msg
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()
acpError = nil
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 {
let msg = error.localizedDescription
logger.error("Failed to start ACP session: \(msg)")
acpStatus = "Failed"
acpError = msg
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()
acpError = nil
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)))"
acpError = nil
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"
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 }
@@ -42,7 +450,44 @@ final class ChatViewModel {
return session.id
}
// MARK: - Voice (terminal mode only)
func toggleVoice() {
guard let tv = terminalView else { return }
if voiceEnabled {
sendToTerminal(tv, text: "/voice off\r")
voiceEnabled = false
isRecording = false
} else {
sendToTerminal(tv, text: "/voice on\r")
voiceEnabled = true
ttsEnabled = fileService.loadConfig().autoTTS
}
}
func toggleTTS() {
guard let tv = terminalView, voiceEnabled else { return }
sendToTerminal(tv, text: "/voice tts\r")
ttsEnabled.toggle()
}
func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return }
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()
@@ -55,6 +500,9 @@ final class ChatViewModel {
let coord = Coordinator(onTerminated: { [weak self] in
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
}
}
+232 -3
View File
@@ -2,29 +2,60 @@ import SwiftUI
struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
@Bindable var vm = viewModel
VStack(spacing: 0) {
toolbar
Divider()
terminalArea
chatArea
}
.navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
}
}
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)
@@ -36,6 +67,21 @@ struct ChatView: View {
Spacer()
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)
@@ -43,6 +89,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()
}
@@ -52,6 +104,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)
@@ -67,6 +121,7 @@ struct ChatView: View {
}
}
}
.disabled(session.id == activeSessionId || session.id == originSessionId)
}
}
} label: {
@@ -80,6 +135,65 @@ struct ChatView: View {
.padding(.vertical, 6)
}
private var voiceControls: some View {
HStack(spacing: 8) {
Button {
viewModel.toggleVoice()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
.font(.caption)
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle voice mode (/voice)")
if viewModel.voiceEnabled {
Button {
viewModel.toggleTTS()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
.font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle text-to-speech (/voice tts)")
Button {
viewModel.pushToTalk()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
.symbolEffect(.pulse, isActive: viewModel.isRecording)
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
.font(.caption)
}
}
.buttonStyle(.plain)
.help("Push to talk (Ctrl+B)")
.keyboardShortcut("b", modifiers: .control)
}
}
}
@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 {
@@ -100,4 +214,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)"
}
}
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(terminalView)
NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false
nsView.addSubview(terminalView)
NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
@@ -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)
}
}
}
}
+35 -1
View File
@@ -38,6 +38,11 @@ struct CronView: View {
.foregroundStyle(.secondary)
}
Spacer()
if job.silent == true {
Text("SILENT")
.font(.caption2.bold())
.foregroundStyle(.purple)
}
if !job.enabled {
Text("Disabled")
.font(.caption2)
@@ -67,7 +72,7 @@ struct CronView: View {
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 {
if let deliver = job.deliveryDisplay {
Label("Deliver: \(deliver)", systemImage: "paperplane")
}
}
@@ -86,6 +91,20 @@ struct CronView: View {
.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")
@@ -118,6 +137,21 @@ struct CronView: View {
.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) {
@@ -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
@@ -3,6 +3,7 @@ import SwiftUI
struct DashboardView: View {
@State private var viewModel = DashboardViewModel()
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
ScrollView {
@@ -16,6 +17,9 @@ struct DashboardView: View {
}
.navigationTitle("Dashboard")
.task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
}
private var statusSection: some View {
@@ -56,7 +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))
StatCard(label: "Est. Cost", value: String(format: "$%.2f", viewModel.stats.totalCostUSD))
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 {
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
}
}
}
}
@@ -87,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 {
@@ -165,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)
@@ -0,0 +1,177 @@
import Foundation
struct GatewayInfo {
let pid: Int?
let state: String
let exitReason: String?
let startTime: String?
let updatedAt: String?
let platforms: [PlatformInfo]
let isLoaded: Bool
let isStale: Bool
}
struct PlatformInfo: Identifiable {
var id: String { name }
let name: String
let state: String
let updatedAt: String?
var isConnected: Bool { state == "connected" }
var icon: String { KnownPlatforms.icon(for: name) }
}
struct PairedUser: Identifiable {
var id: String { platform + userId }
let platform: String
let userId: String
let name: String
}
struct PendingPairing: Identifiable {
var id: String { platform + code }
let platform: String
let code: String
}
@Observable
final class GatewayViewModel {
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
var approvedUsers: [PairedUser] = []
var pendingPairings: [PendingPairing] = []
var isLoading = false
var actionMessage: String?
func load() {
isLoading = true
loadGatewayStatus()
loadPairing()
isLoading = false
}
func startGateway() {
runHermes(["gateway", "start"])
actionMessage = "Gateway start requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func stopGateway() {
runHermes(["gateway", "stop"])
actionMessage = "Gateway stop requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func restartGateway() {
runHermes(["gateway", "restart"])
actionMessage = "Gateway restart requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func approvePairing(platform: String, code: String) {
runHermes(["pairing", "approve", platform, code])
loadPairing()
}
func revokeUser(_ user: PairedUser) {
runHermes(["pairing", "revoke", user.platform, user.userId])
approvedUsers.removeAll { $0.id == user.id }
}
// MARK: - Private
private func loadGatewayStatus() {
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
var pid: Int?
var state = "unknown"
var exitReason: String?
var startTime: String?
var updatedAt: String?
var platforms: [PlatformInfo] = []
if let data = stateJSON,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
pid = json["pid"] as? Int
state = json["gateway_state"] as? String ?? "unknown"
exitReason = json["exit_reason"] as? String
startTime = json["start_time"] as? String
updatedAt = json["updated_at"] as? String
if let plats = json["platforms"] as? [String: Any] {
platforms = plats.compactMap { key, value in
guard let info = value as? [String: Any] else { return nil }
return PlatformInfo(
name: key,
state: info["state"] as? String ?? "unknown",
updatedAt: info["updated_at"] as? String
)
}.sorted { $0.name < $1.name }
}
}
let statusOutput = runHermes(["gateway", "status"]).output
let isLoaded = statusOutput.contains("service is loaded")
let isStale = statusOutput.contains("stale")
gateway = GatewayInfo(
pid: pid, state: state, exitReason: exitReason,
startTime: startTime, updatedAt: updatedAt,
platforms: platforms, isLoaded: isLoaded, isStale: isStale
)
}
private func loadPairing() {
let output = runHermes(["pairing", "list"]).output
approvedUsers = []
pendingPairings = []
var inApproved = false
var inPending = false
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue }
if trimmed.contains("Pending") { inPending = true; inApproved = false; continue }
if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue }
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true)
if inApproved && parts.count >= 3 {
let platform = String(parts[0])
let userId = String(parts[1])
let name = parts[2...].joined(separator: " ")
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
}
if inPending && parts.count >= 2 {
let platform = String(parts[0])
let code = String(parts[1])
pendingPairings.append(PendingPairing(platform: platform, code: code))
}
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
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,197 @@
import SwiftUI
struct GatewayView: View {
@State private var viewModel = GatewayViewModel()
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
serviceSection
platformsSection
pairingSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Gateway")
.onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
}
// MARK: - Service
private var serviceSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Service")
.font(.headline)
Spacer()
if let msg = viewModel.actionMessage {
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Button("Start") { viewModel.startGateway() }
Button("Stop") { viewModel.stopGateway() }
Button("Restart") { viewModel.restartGateway() }
}
.controlSize(.small)
}
HStack(spacing: 16) {
StatusBadge(
label: viewModel.gateway.state,
isActive: viewModel.gateway.state == "running"
)
if let pid = viewModel.gateway.pid {
Label("PID \(pid)", systemImage: "number")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if viewModel.gateway.isLoaded {
Label("Loaded", systemImage: "checkmark.circle")
.font(.caption)
.foregroundStyle(.green)
}
if viewModel.gateway.isStale {
Label("Service definition stale", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let reason = viewModel.gateway.exitReason, !reason.isEmpty {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let updated = viewModel.gateway.updatedAt {
Text("Last updated: \(updated)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: - Platforms
private var platformsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.gateway.platforms.isEmpty {
Text("No platforms connected")
.font(.caption)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.gateway.platforms) { platform in
VStack(spacing: 6) {
Image(systemName: platform.icon)
.font(.title2)
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
Text(platform.name.capitalized)
.font(.caption.bold())
StatusBadge(
label: platform.state,
isActive: platform.isConnected
)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Pairing
private var pairingSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Paired Users")
.font(.headline)
if !viewModel.pendingPairings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Pending Approvals", systemImage: "clock.badge.questionmark")
.font(.caption.bold())
.foregroundStyle(.orange)
ForEach(viewModel.pendingPairings) { pending in
HStack {
Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform))
Text("Code: \(pending.code)")
.font(.caption.monospaced())
Spacer()
Button("Approve") {
viewModel.approvePairing(platform: pending.platform, code: pending.code)
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
}
.font(.caption)
.padding(8)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty {
Text("No paired users")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.approvedUsers) { user in
HStack {
Image(systemName: platformIcon(user.platform))
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(user.name)
Text("\(user.platform.capitalized) · \(user.userId)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Revoke", role: .destructive) {
viewModel.revokeUser(user)
}
.controlSize(.small)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
}
struct StatusBadge: View {
let label: String
let isActive: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isActive ? .green : .secondary)
.frame(width: 6, height: 6)
Text(label)
.font(.caption)
}
}
}
@@ -0,0 +1,220 @@
import Foundation
struct HealthCheck: Identifiable {
let id = UUID()
let label: String
let status: CheckStatus
let detail: String?
enum CheckStatus {
case ok
case warning
case error
}
}
struct HealthSection: Identifiable {
let id = UUID()
let title: String
let icon: String
let checks: [HealthCheck]
}
@Observable
final class HealthViewModel {
private let fileService = HermesFileService()
var version = ""
var updateInfo = ""
var hasUpdate = false
var statusSections: [HealthSection] = []
var doctorSections: [HealthSection] = []
var issueCount = 0
var warningCount = 0
var okCount = 0
var isLoading = false
var hermesRunning = false
var hermesPID: pid_t?
var actionMessage: String?
func load() {
isLoading = true
refreshProcessStatus()
loadVersion()
let statusOutput = runHermes(["status"]).output
statusSections = parseOutput(statusOutput)
let doctorOutput = runHermes(["doctor"]).output
doctorSections = parseOutput(doctorOutput)
computeCounts()
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")
version = lines.first ?? ""
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
hasUpdate = true
} else {
updateInfo = ""
hasUpdate = false
}
}
private func parseOutput(_ output: String) -> [HealthSection] {
var sections: [HealthSection] = []
var currentTitle = ""
var currentChecks: [HealthCheck] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("") {
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("") {
let text = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "", with: "")
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("Error:") {
if !currentChecks.isEmpty {
let last = currentChecks.removeLast()
let extra = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
}
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
let parts = trimmed.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let val = parts[1].trimmingCharacters(in: .whitespaces)
if !key.isEmpty && key.count < 30 {
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
}
}
}
}
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
return sections
}
private func splitCheck(_ text: String) -> (String, String?) {
if let parenStart = text.firstIndex(of: "(") {
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> String {
switch title {
case "Environment": return "gearshape.2"
case "API Keys": return "key"
case "Auth Providers": return "person.badge.key"
case "API-Key Providers": return "key.horizontal"
case "Terminal Backend": return "terminal"
case "Messaging Platforms": return "bubble.left.and.bubble.right"
case "Gateway Service": return "antenna.radiowaves.left.and.right"
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
case "Sessions": return "text.bubble"
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
case "Required Packages": return "shippingbox"
case "Configuration Files": return "doc.text"
case "Directory Structure": return "folder"
case "External Tools": return "wrench"
case "API Connectivity": return "wifi"
case "Submodules": return "arrow.triangle.branch"
case "Tool Availability": return "wrench.and.screwdriver"
case "Skills Hub": return "lightbulb"
case "Honcho Memory": return "brain"
default: return "circle"
}
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
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,258 @@
import SwiftUI
struct HealthView: View {
@State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
Divider()
ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
.padding()
}
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
}
// MARK: - Header
private var headerBar: some View {
VStack(spacing: 0) {
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)
}
.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)
Divider()
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)
}
}
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)
}
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(sections) { section in
SectionCard(
section: section,
isExpanded: expandedSection == section.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedSection = expandedSection == section.id ? nil : section.id
}
}
)
}
}
}
}
// MARK: - Section Card
struct SectionCard: View {
let section: HealthSection
let isExpanded: Bool
let onTap: () -> Void
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
private var accentColor: Color {
if errorCount > 0 { return .red }
if warnCount > 0 { return .orange }
return .green
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 8) {
if okCount > 0 {
HStack(spacing: 2) {
Circle().fill(.green).frame(width: 5, height: 5)
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if warnCount > 0 {
HStack(spacing: 2) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if errorCount > 0 {
HStack(spacing: 2) {
Circle().fill(.red).frame(width: 5, height: 5)
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
}
}
}
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 3) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(12)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Check Row
struct CheckRow: View {
let check: HealthCheck
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 9))
.frame(width: 12, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 0) {
Text(check.label)
.font(.caption)
if let detail = check.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
private var statusIcon: String {
switch check.status {
case .ok: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private var statusColor: Color {
switch check.status {
case .ok: return .green
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Mini Count
struct MiniCount: View {
let count: Int
let color: Color
let icon: String
var body: some View {
HStack(spacing: 3) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.caption2)
Text("\(count)")
.font(.caption.monospaced().bold())
}
}
}
@@ -0,0 +1,240 @@
import Foundation
enum InsightsPeriod: String, CaseIterable, Identifiable {
case week = "7 Days"
case month = "30 Days"
case quarter = "90 Days"
case all = "All Time"
var id: String { rawValue }
var sinceDate: Date {
let calendar = Calendar.current
switch self {
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
case .all: return Date(timeIntervalSince1970: 0)
}
}
}
struct ModelUsage: Identifiable {
var id: String { model }
let model: String
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let reasoningTokens: Int
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
}
struct PlatformUsage: Identifiable {
var id: String { platform }
let platform: String
let sessions: Int
let messages: Int
let tokens: Int
}
struct ToolUsage: Identifiable {
var id: String { name }
let name: String
let count: Int
let percentage: Double
}
struct NotableSession: Identifiable {
var id: String { "\(session.id)-\(label)" }
let label: String
let value: String
let session: HermesSession
let preview: String
}
@Observable
final class InsightsViewModel {
private let dataService = HermesDataService()
var period: InsightsPeriod = .month
var isLoading = true
var sessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var userMessageCount = 0
var totalMessages = 0
var totalToolCalls = 0
var totalInputTokens = 0
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
var modelUsage: [ModelUsage] = []
var platformUsage: [PlatformUsage] = []
var toolUsage: [ToolUsage] = []
var hourlyActivity: [Int: Int] = [:]
var dailyActivity: [Int: Int] = [:]
var notableSessions: [NotableSession] = []
func load() async {
isLoading = true
let opened = await dataService.open()
guard opened else {
isLoading = false
return
}
let since = period.sinceDate
sessions = await dataService.fetchSessionsInPeriod(since: since)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
userMessageCount = await dataService.fetchUserMessageCount(since: since)
let tools = await dataService.fetchToolUsage(since: since)
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
await dataService.close()
computeAggregates()
computeModelBreakdown()
computePlatformBreakdown()
computeToolBreakdown(tools)
computeNotableSessions()
isLoading = false
}
func previewFor(_ session: HermesSession) -> String {
if let title = session.title, !title.isEmpty { return title }
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
return session.id
}
private func computeAggregates() {
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
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
for session in sessions {
if let dur = session.duration, dur > 0 {
total += dur
count += 1
}
}
activeTime = total
avgSessionDuration = count > 0 ? total / Double(count) : 0
}
private func computeModelBreakdown() {
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, 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, reasoningTokens: val.reasoning)
}.sorted { $0.totalTokens > $1.totalTokens }
}
private func computePlatformBreakdown() {
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
for s in sessions {
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 + s.reasoningTokens
grouped[s.source] = entry
}
platformUsage = grouped.map { key, val in
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
}.sorted { $0.sessions > $1.sessions }
}
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
let total = tools.reduce(0) { $0 + $1.count }
toolUsage = tools.map { tool in
ToolUsage(name: tool.name, count: tool.count,
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
}
}
private func computeNotableSessions() {
notableSessions = []
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
notableSessions.append(NotableSession(
label: "Longest Session",
value: formatDuration(longest.duration ?? 0),
session: longest,
preview: previewFor(longest)
))
}
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
notableSessions.append(NotableSession(
label: "Most Messages",
value: "\(mostMsgs.messageCount) msgs",
session: mostMsgs,
preview: previewFor(mostMsgs)
))
}
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
notableSessions.append(NotableSession(
label: "Most Tokens",
value: formatTokens(mostTokens.totalTokens),
session: mostTokens,
preview: previewFor(mostTokens)
))
}
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
notableSessions.append(NotableSession(
label: "Most Tool Calls",
value: "\(mostTools.toolCallCount) calls",
session: mostTools,
preview: previewFor(mostTools)
))
}
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
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,312 @@
import SwiftUI
struct InsightsView: View {
@State private var viewModel = InsightsViewModel()
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
periodPicker
overviewSection
modelSection
platformSection
toolsSection
activitySection
notableSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Insights")
.task { await viewModel.load() }
.onChange(of: viewModel.period) {
Task { await viewModel.load() }
}
}
private var periodPicker: some View {
Picker("Period", selection: $viewModel.period) {
ForEach(InsightsPeriod.allCases) { period in
Text(period.rawValue).tag(period)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 400)
}
// MARK: - Overview
private var overviewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Overview")
.font(.headline)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)")
InsightCard(label: "Messages", value: "\(viewModel.totalMessages)")
InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)")
InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)")
InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens))
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)))
}
}
}
// MARK: - Models
private var modelSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Models")
.font(.headline)
if viewModel.modelUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.modelUsage) { model in
HStack {
Image(systemName: "cpu")
.foregroundStyle(.blue)
.frame(width: 20)
Text(model.model)
.font(.system(.body, design: .monospaced))
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(model.sessions) sessions")
.font(.caption)
Text(formatTokens(model.totalTokens) + " tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Platforms
private var platformSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.platformUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.platformUsage) { platform in
VStack(spacing: 6) {
Image(systemName: platformIcon(platform.platform))
.font(.title2)
.foregroundStyle(Color.accentColor)
Text(platform.platform)
.font(.caption.bold())
Text("\(platform.sessions) sessions")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(platform.messages) msgs")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Tools
private var toolsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top Tools")
.font(.headline)
if viewModel.toolUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
let maxCount = viewModel.toolUsage.first?.count ?? 1
ForEach(viewModel.toolUsage.prefix(15)) { tool in
HStack(spacing: 10) {
Text(tool.name)
.font(.system(.caption, design: .monospaced))
.frame(width: 140, alignment: .trailing)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: tool.name))
.frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount)))
}
.frame(height: 16)
Text("\(tool.count)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing)
Text(String(format: "%.1f%%", tool.percentage))
.font(.caption)
.foregroundStyle(.tertiary)
.frame(width: 50, alignment: .trailing)
}
.frame(height: 20)
}
}
}
}
// MARK: - Activity Patterns
private var activitySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity Patterns")
.font(.headline)
HStack(alignment: .top, spacing: 24) {
dayOfWeekChart
hourlyChart
}
}
}
private var dayOfWeekChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Day")
.font(.caption.bold())
.foregroundStyle(.secondary)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
ForEach(0..<7, id: \.self) { day in
let count = viewModel.dailyActivity[day] ?? 0
HStack(spacing: 6) {
Text(dayNames[day])
.font(.caption.monospaced())
.frame(width: 30, alignment: .trailing)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor.opacity(0.7))
.frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14)
if count > 0 {
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}
private var hourlyChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Hour")
.font(.caption.bold())
.foregroundStyle(.secondary)
let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1)
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<24, id: \.self) { hour in
let count = viewModel.hourlyActivity[hour] ?? 0
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15))
.frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80))
if hour % 6 == 0 {
Text("\(hour)")
.font(.system(size: 8))
.foregroundStyle(.secondary)
} else {
Text("")
.font(.system(size: 8))
}
}
}
}
}
}
// MARK: - Notable Sessions
private var notableSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Notable Sessions")
.font(.headline)
if viewModel.notableSessions.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.notableSessions) { notable in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(notable.label)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(notable.preview)
.lineLimit(1)
}
Spacer()
Text(notable.value)
.font(.system(.body, design: .monospaced, weight: .semibold))
Button {
coordinator.selectedSessionId = notable.session.id
coordinator.selectedSection = .sessions
} label: {
Image(systemName: "arrow.right.circle")
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.help("Open session")
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Helpers
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
private func barColor(for toolName: String) -> Color {
switch toolName {
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
case _ where toolName.hasPrefix("browser"): return .indigo
case "memory": return .pink
case "vision", "image_gen": return .mint
default: return Color.accentColor
}
}
}
struct InsightCard: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(.title3, design: .monospaced, weight: .semibold))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -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,74 @@
import Foundation
@Observable
final class ProjectsViewModel {
private let service = ProjectDashboardService()
var projects: [ProjectEntry] = []
var selectedProject: ProjectEntry?
var dashboard: ProjectDashboard?
var dashboardError: String?
var isLoading = false
func load() {
let registry = service.loadRegistry()
projects = registry.projects
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
selectedProject = nil
dashboard = nil
}
if let selected = selectedProject {
loadDashboard(for: selected)
}
}
func selectProject(_ project: ProjectEntry) {
selectedProject = project
loadDashboard(for: project)
}
func addProject(name: String, path: String) {
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == name }) else { return }
let entry = ProjectEntry(name: name, path: path)
registry.projects.append(entry)
service.saveRegistry(registry)
projects = registry.projects
selectProject(entry)
}
func removeProject(_ project: ProjectEntry) {
var registry = service.loadRegistry()
registry.projects.removeAll { $0.name == project.name }
service.saveRegistry(registry)
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
}
var dashboardPaths: [String] {
projects.map(\.dashboardPath)
}
private func loadDashboard(for project: ProjectEntry) {
dashboardError = nil
if !service.dashboardExists(for: project) {
dashboard = nil
dashboardError = "No dashboard found at \(project.dashboardPath)"
return
}
if let loaded = service.loadDashboard(for: project) {
dashboard = loaded
} else {
dashboard = nil
dashboardError = "Failed to parse dashboard JSON"
}
}
}
@@ -0,0 +1,330 @@
import SwiftUI
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
}
struct ProjectsView: View {
@State private var viewModel = ProjectsViewModel()
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var showingAddSheet = false
@State private var selectedTab: DashboardTab = .dashboard
var body: some View {
HSplitView {
projectList
.frame(minWidth: 180, maxWidth: 220)
dashboardArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Projects")
.task {
viewModel.load()
if let name = coordinator.selectedProjectName,
let project = viewModel.projects.first(where: { $0.name == name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
// MARK: - Project List
private var projectList: some View {
VStack(spacing: 0) {
List(viewModel.projects, selection: Binding(
get: { viewModel.selectedProject },
set: { project in
if let project {
viewModel.selectProject(project)
}
}
)) { project in
HStack {
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
? "square.grid.2x2.fill" : "square.grid.2x2")
.foregroundStyle(.secondary)
Text(project.name)
}
.tag(project)
}
.listStyle(.sidebar)
Divider()
HStack {
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
Spacer()
if let selected = viewModel.selectedProject {
Button(action: { viewModel.removeProject(selected) }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
}
}
.padding(8)
}
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
}
// MARK: - Dashboard Area
/// First webview widget found across all sections, if any.
private var siteWidget: DashboardWidget? {
viewModel.dashboard?.sections
.flatMap(\.widgets)
.first { $0.type == "webview" }
}
@ViewBuilder
private var dashboardArea: some View {
if let dashboard = viewModel.dashboard {
VStack(spacing: 0) {
dashboardHeader(dashboard)
.padding(.horizontal)
.padding(.top)
.padding(.bottom, 8)
if siteWidget != nil {
tabBar
.padding(.horizontal)
.padding(.bottom, 8)
}
switch selectedTab {
case .dashboard:
widgetsTab(dashboard)
case .site:
if let widget = siteWidget {
siteTab(widget)
} else {
widgetsTab(dashboard)
}
}
}
} else if let error = viewModel.dashboardError {
ContentUnavailableView {
Label("No Dashboard", systemImage: "square.grid.2x2")
} description: {
Text(error)
}
} else if viewModel.projects.isEmpty {
ContentUnavailableView {
Label("No Projects", systemImage: "square.grid.2x2")
} description: {
Text("Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.")
} actions: {
Button("Add Project") { showingAddSheet = true }
}
} else {
ContentUnavailableView {
Label("Select a Project", systemImage: "square.grid.2x2")
} description: {
Text("Choose a project from the sidebar to view its dashboard.")
}
}
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(DashboardTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
.font(.caption)
Text(tab.rawValue)
.font(.subheadline)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
ForEach(dashboard.sections) { section in
DashboardSectionView(section: section)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private func siteTab(_ widget: DashboardWidget) -> some View {
WebviewWidgetView(widget: widget, fullCanvas: true)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(dashboard.title)
.font(.title2.bold())
if let desc = dashboard.description {
Text(desc)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
if let updated = dashboard.updatedAt {
Text("Updated: \(updated)")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: { viewModel.refreshDashboard() }) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
if let project = viewModel.selectedProject {
Button(action: { openInFinder(project.path) }) {
Image(systemName: "folder")
}
.buttonStyle(.borderless)
}
}
}
private func openInFinder(_ path: String) {
NSWorkspace.shared.open(URL(fileURLWithPath: path))
}
}
// MARK: - Section View
struct DashboardSectionView: View {
let section: DashboardSection
/// Filter out webview widgets those are rendered in the Site tab instead.
private var displayWidgets: [DashboardWidget] {
section.widgets.filter { $0.type != "webview" }
}
var body: some View {
if !displayWidgets.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(section.title)
.font(.headline)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
spacing: 12
) {
ForEach(displayWidgets) { widget in
WidgetView(widget: widget)
}
}
}
}
}
}
// MARK: - Widget Dispatcher
struct WidgetView: View {
let widget: DashboardWidget
var body: some View {
Group {
switch widget.type {
case "stat":
StatWidgetView(widget: widget)
case "progress":
ProgressWidgetView(widget: widget)
case "text":
TextWidgetView(widget: widget)
case "table":
TableWidgetView(widget: widget)
case "chart":
ChartWidgetView(widget: widget)
case "list":
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default:
VStack {
Image(systemName: "questionmark.square.dashed")
.font(.title2)
.foregroundStyle(.secondary)
Text("Unknown: \(widget.type)")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Add Project Sheet
struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var projectName = ""
@State private var projectPath = ""
let onAdd: (String, String) -> Void
var body: some View {
VStack(spacing: 16) {
Text("Add Project")
.font(.headline)
TextField("Project Name", text: $projectName)
.textFieldStyle(.roundedBorder)
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
}
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add") {
guard !projectName.isEmpty, !projectPath.isEmpty else { return }
onAdd(projectName, projectPath)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(projectName.isEmpty || projectPath.isEmpty)
}
}
.padding()
.frame(width: 400)
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Charts
// Flattened data point for Charts to avoid complex nested generic inference
private struct PlottablePoint: Identifiable {
let id = UUID()
let seriesName: String
let x: String
let y: Double
let color: Color
}
struct ChartWidgetView: View {
let widget: DashboardWidget
private var points: [PlottablePoint] {
guard let series = widget.series else { return [] }
return series.flatMap { s in
let color = parseColor(s.color)
return s.data.map { d in
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
chartContent
.frame(height: 150)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var chartContent: some View {
switch widget.chartType {
case "pie":
pieChart
case "bar":
barChart
default:
lineChart
}
}
private var lineChart: some View {
Chart(points) { point in
LineMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
.symbol(by: .value("Series", point.seriesName))
}
}
private var barChart: some View {
Chart(points) { point in
BarMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
}
}
private var pieChart: some View {
Chart(points) { point in
SectorMark(
angle: .value(point.x, point.y),
innerRadius: .ratio(0.5)
)
.foregroundStyle(point.color)
}
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct ListWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
}
}
}
@@ -0,0 +1,32 @@
import SwiftUI
struct ProgressWidgetView: View {
let widget: DashboardWidget
private var progressValue: Double {
switch widget.value {
case .number(let n): return n
default: return 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: progressValue) {
if let label = widget.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.tint(parseColor(widget.color))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct StatWidgetView: View {
let widget: DashboardWidget
private var widgetColor: Color {
parseColor(widget.color)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(widgetColor)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let value = widget.value {
Text(value.displayString)
.font(.system(.title2, design: .monospaced, weight: .semibold))
}
if let subtitle = widget.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(widgetColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct TableWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let columns = widget.columns, let rows = widget.rows {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
}
Divider()
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell)
.font(.callout)
}
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,25 @@
import SwiftUI
struct TextWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let content = widget.content {
if widget.format == "markdown" {
MarkdownContentView(content: content)
} else {
Text(content)
.font(.callout)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,116 @@
import SwiftUI
import WebKit
struct WebviewWidgetView: View {
let widget: DashboardWidget
var fullCanvas: Bool = false
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
if fullCanvas {
fullCanvasView
} else {
cardView
}
}
// MARK: - Full Canvas (Site tab)
private var fullCanvasView: some View {
VStack(spacing: 0) {
if let url = webURL {
WebViewRepresentable(url: url)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Card (inline widget)
private var cardView: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,19 @@
import SwiftUI
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "blue": return .blue
case "purple": return .purple
case "pink": return .pink
case "teal", "cyan": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "brown": return .brown
case "gray", "grey": return .gray
default: return .blue
}
}
@@ -1,4 +1,13 @@
import Foundation
import AppKit
import UniformTypeIdentifiers
struct SessionStoreStats {
let totalSessions: Int
let totalMessages: Int
let databaseSize: String
let platformCounts: [(platform: String, count: Int)]
}
@Observable
final class SessionsViewModel {
@@ -11,12 +20,21 @@ final class SessionsViewModel {
var searchText = ""
var searchResults: [HermesMessage] = []
var isSearching = false
var storeStats: SessionStoreStats?
var subagentSessions: [HermesSession] = []
var renameSessionId: String?
var renameText = ""
var showRenameSheet = false
var showDeleteConfirmation = false
var deleteSessionId: String?
func load() async {
let opened = await dataService.open()
guard opened else { return }
sessions = await dataService.fetchSessions(limit: 500)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
computeStats()
}
func previewFor(_ session: HermesSession) -> String {
@@ -28,6 +46,7 @@ final class SessionsViewModel {
func selectSession(_ session: HermesSession) async {
selectedSession = session
messages = await dataService.fetchMessages(sessionId: session.id)
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
}
func selectSessionById(_ id: String) async {
@@ -50,4 +69,122 @@ final class SessionsViewModel {
func cleanup() async {
await dataService.close()
}
// MARK: - Session Actions
func beginRename(_ session: HermesSession) {
renameSessionId = session.id
renameText = previewFor(session)
showRenameSheet = true
}
func confirmRename() {
guard let sessionId = renameSessionId else { return }
let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
let result = runHermes(["sessions", "rename", sessionId, title])
if result.exitCode == 0 {
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
let updated = sessions[idx].withTitle(title)
sessions[idx] = updated
if selectedSession?.id == sessionId {
selectedSession = updated
}
}
sessionPreviews[sessionId] = title
}
showRenameSheet = false
renameSessionId = nil
}
func beginDelete(_ session: HermesSession) {
deleteSessionId = session.id
showDeleteConfirmation = true
}
func confirmDelete() {
guard let sessionId = deleteSessionId else { return }
let result = runHermes(["sessions", "delete", "--yes", sessionId])
if result.exitCode == 0 {
sessions.removeAll { $0.id == sessionId }
if selectedSession?.id == sessionId {
selectedSession = nil
messages = []
}
computeStats()
}
showDeleteConfirmation = false
deleteSessionId = nil
}
func exportSession(_ session: HermesSession) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(session.id).jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path, "--session-id", session.id])
}
func exportAll() {
let panel = NSSavePanel()
panel.nameFieldStringValue = "hermes-sessions.jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path])
}
// MARK: - Stats
private func computeStats() {
let totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
var platformCounts: [String: Int] = [:]
for s in sessions {
platformCounts[s.source, default: 0] += 1
}
let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) }
let dbPath = HermesPaths.stateDB
let fileSize: String
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
let size = attrs[.size] as? Int {
if Double(size) >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
}
} else {
fileSize = "unknown"
}
storeStats = SessionStoreStats(
totalSessions: sessions.count,
totalMessages: totalMessages,
databaseSize: fileSize,
platformCounts: sorted
)
}
// MARK: - Hermes CLI
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -3,11 +3,19 @@ import SwiftUI
struct SessionDetailView: View {
let session: HermesSession
let messages: [HermesMessage]
var subagentSessions: [HermesSession] = []
var preview: String?
var onRename: (() -> Void)?
var onExport: (() -> Void)?
var onDelete: (() -> Void)?
var onSelectSubagent: ((HermesSession) -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
sessionHeader
if !subagentSessions.isEmpty {
subagentSection
}
Divider()
messagesList
}
@@ -16,15 +24,43 @@ struct SessionDetailView: View {
private var sessionHeader: some View {
VStack(alignment: .leading, spacing: 6) {
Text(preview ?? session.displayTitle)
.font(.title3.bold())
HStack {
Text(preview ?? session.displayTitle)
.font(.title3.bold())
Spacer()
if onRename != nil || onExport != nil || onDelete != nil {
Menu {
if let onRename { Button("Rename...") { onRename() } }
if let onExport { Button("Export...") { onExport() } }
if let onDelete {
Divider()
Button("Delete...", role: .destructive) { onDelete() }
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
HStack(spacing: 16) {
Label(session.source, systemImage: session.sourceIcon)
if session.isSubagent {
Label("Subagent", systemImage: "arrow.triangle.branch")
.foregroundStyle(.orange)
}
if let userId = session.userId, !userId.isEmpty, session.source != "cli" {
Label(userId, systemImage: "person")
}
Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench")
if let cost = session.estimatedCostUSD {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
if session.reasoningTokens > 0 {
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
}
if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
@@ -32,10 +68,46 @@ struct SessionDetailView: View {
}
.font(.caption)
.foregroundStyle(.secondary)
Text(session.id)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
.padding()
}
private var subagentSection: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
Text("Subagent Sessions (\(subagentSessions.count))")
.font(.caption.bold())
.foregroundStyle(.secondary)
ForEach(subagentSessions) { sub in
Button {
onSelectSubagent?(sub)
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(.orange)
Text(sub.displayTitle)
.lineLimit(1)
Spacer()
Text(sub.model ?? "")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("\(sub.messageCount) msgs")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.font(.caption)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
private var messagesList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
@@ -56,9 +128,23 @@ struct MessageBubble: View {
HStack {
if message.isUser { Spacer(minLength: 60) }
VStack(alignment: .leading, spacing: 6) {
if message.hasReasoning {
DisclosureGroup("Reasoning") {
Text(message.reasoning ?? "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.font(.caption.bold())
.foregroundStyle(.orange)
}
if !message.content.isEmpty {
Text(message.content)
.textSelection(.enabled)
if message.isAssistant {
MarkdownContentView(content: message.content)
} else {
Text(message.content)
.textSelection(.enabled)
}
}
if !message.toolCalls.isEmpty {
ForEach(message.toolCalls) { call in
@@ -5,11 +5,17 @@ struct SessionsView: View {
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
HSplitView {
sessionList
.frame(minWidth: 280, idealWidth: 320)
sessionDetail
.frame(minWidth: 400)
VStack(spacing: 0) {
if let stats = viewModel.storeStats {
statsBar(stats)
Divider()
}
HSplitView {
sessionList
.frame(minWidth: 280, idealWidth: 320)
sessionDetail
.frame(minWidth: 400)
}
}
.navigationTitle("Sessions")
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
@@ -28,6 +34,33 @@ struct SessionsView: View {
}
}
.onDisappear { Task { await viewModel.cleanup() } }
.sheet(isPresented: $viewModel.showRenameSheet) {
renameSheet
}
.confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) {
Button("Delete", role: .destructive) { viewModel.confirmDelete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently delete the session and all its messages.")
}
}
private func statsBar(_ stats: SessionStoreStats) -> some View {
HStack(spacing: 16) {
Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right")
Label("\(stats.totalMessages) messages", systemImage: "text.bubble")
Label(stats.databaseSize, systemImage: "internaldrive")
ForEach(stats.platformCounts, id: \.platform) { item in
Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform))
}
Spacer()
Button("Export All") { viewModel.exportAll() }
.controlSize(.small)
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
}
private var sessionList: some View {
@@ -64,6 +97,12 @@ struct SessionsView: View {
ForEach(viewModel.sessions) { session in
SessionRow(session: session, preview: viewModel.previewFor(session))
.tag(session.id)
.contextMenu {
Button("Rename...") { viewModel.beginRename(session) }
Button("Export...") { viewModel.exportSession(session) }
Divider()
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
}
}
}
}
@@ -73,11 +112,47 @@ struct SessionsView: View {
@ViewBuilder
private var sessionDetail: some View {
if let session = viewModel.selectedSession {
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
SessionDetailView(
session: session,
messages: viewModel.messages,
subagentSessions: viewModel.subagentSessions,
preview: viewModel.previewFor(session),
onRename: { viewModel.beginRename(session) },
onExport: { viewModel.exportSession(session) },
onDelete: { viewModel.beginDelete(session) },
onSelectSubagent: { sub in
Task { await viewModel.selectSession(sub) }
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var renameSheet: some View {
VStack(spacing: 16) {
Text("Rename Session")
.font(.headline)
TextField("Session title", text: $viewModel.renameText)
.textFieldStyle(.roundedBorder)
.onSubmit { viewModel.confirmRename() }
HStack {
Button("Cancel") { viewModel.showRenameSheet = false }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Rename") { viewModel.confirmRename() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(width: 400)
}
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
}
@@ -1,4 +1,6 @@
import Foundation
import AppKit
import UniformTypeIdentifiers
@Observable
final class SettingsViewModel {
@@ -8,11 +10,185 @@ final class SettingsViewModel {
var gatewayState: GatewayState?
var hermesRunning = false
var rawConfigYAML = ""
var personalities: [String] = []
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
var browserBackends = ["browseruse", "firecrawl", "local"]
var saveMessage: String?
var showAuthRemoveConfirmation = false
func load() {
config = fileService.loadConfig()
gatewayState = fileService.loadGatewayState()
hermesRunning = fileService.isHermesRunning()
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
do {
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
rawConfigYAML = ""
}
personalities = parsePersonalities()
}
func setSetting(_ key: String, value: String) {
let result = runHermes(["config", "set", key, value])
if result.exitCode == 0 {
saveMessage = "Saved \(key)"
config = fileService.loadConfig()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
}
func setModel(_ value: String) { setSetting("model.default", value: value) }
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
// MARK: - Backup & Restore (v0.9.0)
var backupInProgress = false
func runBackup() {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
let zipPath = Self.extractZipPath(from: result.output)
await MainActor.run {
self.backupInProgress = false
if result.exitCode == 0 {
if let zipPath {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
self.saveMessage = "Backup saved"
} else {
self.saveMessage = "Backup complete"
}
} else {
self.saveMessage = "Backup failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
}
}
func runRestore(from url: URL) {
backupInProgress = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
await MainActor.run {
self.backupInProgress = false
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
if result.exitCode == 0 {
self.load()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.saveMessage = nil
}
}
}
}
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
nonisolated static func extractZipPath(from output: String) -> String? {
let pattern = #"(/[^\s]+\.zip)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(output.startIndex..., in: output)
guard let match = regex.firstMatch(in: output, range: range),
let r = Range(match.range(at: 1), in: output) else { return nil }
return String(output[r])
}
func presentRestorePicker() -> URL? {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.zip]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.message = "Choose a Hermes backup archive to restore"
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
func removeAuth() {
let result = runHermes(["auth", "remove"])
if result.exitCode == 0 {
saveMessage = "Credentials removed"
} else {
saveMessage = "Failed to remove credentials"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
func openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
private func parsePersonalities() -> [String] {
var names: [String] = []
var inPersonalities = false
for line in rawConfigYAML.components(separatedBy: "\n") {
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
inPersonalities = true
continue
}
if inPersonalities {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
let indent = line.prefix(while: { $0 == " " }).count
if indent <= 2 && !trimmed.isEmpty {
inPersonalities = false
continue
}
if indent == 4 && trimmed.contains(":") {
let name = String(trimmed.split(separator: ":")[0])
names.append(name)
}
}
}
return names
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
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)
}
}
}
@@ -6,9 +6,23 @@ struct SettingsView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
configSection
gatewaySection
VStack(alignment: .leading, spacing: 24) {
headerBar
modelSection
displaySection
terminalSection
if !viewModel.config.dockerEnv.isEmpty {
dockerEnvSection
}
if !viewModel.config.commandAllowlist.isEmpty {
allowlistSection
}
voiceSection
memorySection
performanceSection
networkSection
advancedSection
backupSection
pathsSection
rawConfigSection
}
@@ -17,64 +31,220 @@ struct SettingsView: View {
}
.navigationTitle("Settings")
.onAppear { viewModel.load() }
}
private var configSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Configuration")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
SettingRow(label: "Model", value: viewModel.config.model)
SettingRow(label: "Provider", value: viewModel.config.provider)
SettingRow(label: "Personality", value: viewModel.config.personality)
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
}
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
Button("Remove", role: .destructive) { viewModel.removeAuth() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently clear all stored provider credentials.")
}
}
private var gatewaySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Gateway")
.font(.headline)
HStack(spacing: 16) {
Label(
viewModel.gatewayState?.statusText ?? "unknown",
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
)
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
if let reason = viewModel.gatewayState?.exitReason {
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
private var headerBar: some View {
HStack {
if let msg = viewModel.saveMessage {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Open in Editor") { viewModel.openConfigInEditor() }
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
}
// MARK: - Model & Provider
private var modelSection: some View {
SettingsSection(title: "Model", icon: "cpu") {
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
HStack {
Text("Credentials")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Button("Remove Credentials", role: .destructive) {
viewModel.showAuthRemoveConfirmation = true
}
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
// MARK: - Display
private var displaySection: some View {
SettingsSection(title: "Display", icon: "paintbrush") {
if !viewModel.personalities.isEmpty {
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
} else {
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
}
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
}
}
// MARK: - Terminal
private var terminalSection: some View {
SettingsSection(title: "Terminal", icon: "terminal") {
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
}
}
// MARK: - Docker Environment
private var dockerEnvSection: some View {
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
ReadOnlyRow(label: key, value: value)
}
}
}
// MARK: - Command Allowlist
private var allowlistSection: some View {
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
}
}
// MARK: - Voice
private var voiceSection: some View {
SettingsSection(title: "Voice", icon: "mic") {
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
}
}
// MARK: - Memory
private var memorySection: some View {
SettingsSection(title: "Memory", icon: "brain") {
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
if !viewModel.config.memoryProfile.isEmpty {
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
}
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
if viewModel.config.memoryProvider == "honcho" {
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
}
}
}
// MARK: - Performance (v0.9.0)
private var performanceSection: some View {
SettingsSection(title: "Performance", icon: "bolt") {
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
viewModel.setServiceTier(on ? "fast" : "normal")
}
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) }
}
}
// MARK: - Network (v0.9.0)
private var networkSection: some View {
SettingsSection(title: "Network", icon: "network") {
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
}
}
// MARK: - Advanced (v0.9.0)
private var advancedSection: some View {
SettingsSection(title: "Advanced", icon: "slider.horizontal.3") {
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
}
}
// MARK: - Backup & Restore (v0.9.0)
@State private var showRestoreConfirm = false
@State private var pendingRestoreURL: URL?
private var backupSection: some View {
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
HStack {
Text("Archive")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Button {
viewModel.runBackup()
} label: {
Label("Backup Now", systemImage: "arrow.down.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
Button {
if let url = viewModel.presentRestorePicker() {
pendingRestoreURL = url
showRestoreConfirm = true
}
} label: {
Label("Restore…", systemImage: "arrow.up.doc")
}
.controlSize(.small)
.disabled(viewModel.backupInProgress)
if viewModel.backupInProgress {
ProgressView().controlSize(.small)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
Button("Restore", role: .destructive) {
if let url = pendingRestoreURL {
viewModel.runRestore(from: url)
}
pendingRestoreURL = nil
}
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
} message: {
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
}
}
// MARK: - Paths
private var pathsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Paths")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
PathRow(label: "Hermes Home", path: HermesPaths.home)
PathRow(label: "State DB", path: HermesPaths.stateDB)
PathRow(label: "Config", path: HermesPaths.configYAML)
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Logs", path: HermesPaths.errorsLog)
}
SettingsSection(title: "Paths", icon: "folder") {
PathRow(label: "Hermes Home", path: HermesPaths.home)
PathRow(label: "State DB", path: HermesPaths.stateDB)
PathRow(label: "Config", path: HermesPaths.configYAML)
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
}
}
// MARK: - Raw Config
private var rawConfigSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -98,7 +268,147 @@ struct SettingsView: View {
}
}
struct SettingRow: View {
// MARK: - Reusable Components
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.headline)
VStack(spacing: 1) {
content
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
struct EditableTextField: View {
let label: String
let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
if isEditing {
TextField(label, text: $text, onCommit: {
if text != value { onCommit(text) }
isEditing = false
})
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button("Cancel") { isEditing = false }
.controlSize(.mini)
} else {
Text(value)
.font(.system(.caption, design: .monospaced))
Spacer()
Button("Edit") {
text = value
isEditing = true
}
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PickerRow: View {
let label: String
let selection: String
let options: [String]
let onChange: (String) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Picker("", selection: Binding(
get: { selection },
set: { onChange($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.frame(maxWidth: 250)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ToggleRow: View {
let label: String
let isOn: Bool
let onChange: (Bool) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Toggle("", isOn: Binding(
get: { isOn },
set: { onChange($0) }
))
.toggleStyle(.switch)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct StepperRow: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let onChange: (Int) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text("\(value)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ReadOnlyRow: View {
let label: String
let value: String
@@ -107,10 +417,15 @@ struct SettingRow: View {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 120, alignment: .trailing)
.frame(width: 130, alignment: .trailing)
Text(value)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -123,10 +438,11 @@ struct PathRow: View {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 100, alignment: .trailing)
.frame(width: 130, alignment: .trailing)
Text(path)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
} label: {
@@ -135,5 +451,8 @@ struct PathRow: View {
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -9,6 +9,10 @@ final class SkillsViewModel {
var skillContent = ""
var selectedFileName: String?
var searchText = ""
var missingConfig: [String] = []
var isEditing = false
var editText = ""
private var currentConfig = HermesConfig.empty
var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories }
@@ -28,6 +32,7 @@ final class SkillsViewModel {
func load() {
categories = fileService.loadSkills()
currentConfig = fileService.loadConfig()
}
func selectSkill(_ skill: HermesSkill) {
@@ -40,6 +45,17 @@ final class SkillsViewModel {
selectedFileName = nil
skillContent = ""
}
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
}
func selectFile(_ file: String) {
@@ -47,4 +63,29 @@ final class SkillsViewModel {
selectedFileName = file
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
}
var isMarkdownFile: Bool {
selectedFileName?.hasSuffix(".md") == true
}
private var currentFilePath: String? {
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
return skill.path + "/" + file
}
func startEditing() {
editText = skillContent
isEditing = true
}
func saveEdit() {
guard let path = currentFilePath else { return }
fileService.saveSkillContent(path: path, content: editText)
skillContent = editText
isEditing = false
}
func cancelEditing() {
isEditing = false
}
}
@@ -53,9 +53,28 @@ struct SkillsView: View {
HStack {
Label(skill.category, systemImage: "folder")
Label("\(skill.files.count) files", systemImage: "doc")
if !skill.requiredConfig.isEmpty {
Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape")
}
}
.font(.caption)
.foregroundStyle(.secondary)
if !viewModel.missingConfig.isEmpty {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
VStack(alignment: .leading, spacing: 2) {
Text("Missing required config:")
.font(.caption.bold())
Text(viewModel.missingConfig.joined(separator: ", "))
.font(.caption.monospaced())
}
}
.foregroundStyle(.orange)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Divider()
if !skill.files.isEmpty {
VStack(alignment: .leading, spacing: 4) {
@@ -80,17 +99,57 @@ struct SkillsView: View {
}
if !viewModel.skillContent.isEmpty {
Divider()
Text(viewModel.skillContent)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
HStack {
Spacer()
Button("Edit") { viewModel.startEditing() }
.controlSize(.small)
}
if viewModel.isMarkdownFile {
MarkdownContentView(content: viewModel.skillContent)
} else {
Text(viewModel.skillContent)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.sheet(isPresented: $viewModel.isEditing) {
skillEditorSheet
}
} else {
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var skillEditorSheet: some View {
VStack(spacing: 0) {
HStack {
Text("Edit \(viewModel.selectedFileName ?? "File")")
.font(.headline)
Spacer()
Button("Cancel") { viewModel.cancelEditing() }
Button("Save") { viewModel.saveEdit() }
.buttonStyle(.borderedProminent)
}
.padding()
Divider()
HSplitView {
TextEditor(text: $viewModel.editText)
.font(.system(.body, design: .monospaced))
.padding(8)
if viewModel.isMarkdownFile {
ScrollView {
MarkdownContentView(content: viewModel.editText)
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
}
}
.frame(minWidth: 800, minHeight: 500)
}
}
@@ -0,0 +1,168 @@
import Foundation
import os
@Observable
final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
var availablePlatforms: [HermesToolPlatform] = []
@MainActor
func load() async {
isLoading = true
await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
}
@MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform
await loadTools(for: platform)
}
@MainActor
func toggleTool(_ tool: HermesToolset) async {
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
toolsets[idx].enabled.toggle()
let newEnabled = toolsets[idx].enabled
let action = newEnabled ? "enable" : "disable"
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode != 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled = !newEnabled
}
}
}
@MainActor
private func loadPlatforms() async {
let config: String
do {
config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch {
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = []
var inSection = false
for line in config.components(separatedBy: "\n") {
if line.hasPrefix("platform_toolsets:") {
inSection = true
continue
}
if inSection {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
if !trimmed.isEmpty { break }
continue
}
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
platforms.append(known)
} else {
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
}
}
}
}
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
let first = availablePlatforms.first {
selectedPlatform = first
}
}
@MainActor
private func loadTools(for platform: HermesToolPlatform) async {
let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output)
}
@MainActor
private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func parseToolsList(_ output: String) -> [HermesToolset] {
var tools: [HermesToolset] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isEnabled: Bool
if trimmed.hasPrefix("✓ enabled") {
isEnabled = true
} else if trimmed.hasPrefix("✗ disabled") {
isEnabled = false
} else {
continue
}
let rest = trimmed
.replacingOccurrences(of: "✓ enabled", with: "")
.replacingOccurrences(of: "✗ disabled", with: "")
.trimmingCharacters(in: .whitespaces)
let parts = rest.split(separator: " ", maxSplits: 1)
guard let namePart = parts.first else { continue }
let name = String(namePart)
let rawDesc = parts.count > 1 ? String(parts[1]) : name
let icon = extractEmoji(from: rawDesc)
let description = rawDesc
.unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII }
.map { String($0) }.joined()
.trimmingCharacters(in: .whitespaces)
tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled))
}
return tools
}
private func extractEmoji(from text: String) -> String {
for scalar in text.unicodeScalars {
if scalar.properties.isEmoji && !scalar.isASCII {
return String(scalar)
}
}
return "🔧"
}
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
await Task.detached {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
process.waitUntilExit()
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return (output, process.terminationStatus)
} catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1)
}
}.value
}
}
@@ -0,0 +1,108 @@
import SwiftUI
struct ToolsView: View {
@State private var viewModel = ToolsViewModel()
var body: some View {
VStack(spacing: 0) {
platformPicker
Divider()
toolsList
if !viewModel.mcpStatus.isEmpty {
Divider()
mcpSection
}
}
.navigationTitle("Tools")
.task { await viewModel.load() }
}
private var platformPicker: some View {
HStack(spacing: 12) {
Picker("Platform", selection: Binding(
get: { viewModel.selectedPlatform.name },
set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
Task { await viewModel.switchPlatform(platform) }
}
}
)) {
ForEach(viewModel.availablePlatforms) { platform in
Text(platform.displayName).tag(platform.name)
}
}
.pickerStyle(.segmented)
Spacer()
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private var toolsList: some View {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) {
await viewModel.toggleTool(tool)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.id(viewModel.selectedPlatform.name)
}
private var mcpSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("MCP Servers")
.font(.caption.bold())
.foregroundStyle(.secondary)
if viewModel.mcpStatus.contains("No MCP servers") {
Label("No MCP servers configured", systemImage: "server.rack")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(viewModel.mcpStatus)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ToolRow: View {
let tool: HermesToolset
let onToggle: () async -> Void
var body: some View {
HStack(spacing: 12) {
Text(tool.icon)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(tool.name)
.font(.system(.body, design: .monospaced, weight: .medium))
Text(tool.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { tool.enabled },
set: { _ in Task { await onToggle() } }
))
.toggleStyle(.switch)
.labelsHidden()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -2,12 +2,18 @@ import Foundation
enum SidebarSection: String, CaseIterable, Identifiable {
case dashboard = "Dashboard"
case insights = "Insights"
case sessions = "Sessions"
case activity = "Activity"
case projects = "Projects"
case chat = "Chat"
case memory = "Memory"
case skills = "Skills"
case tools = "Tools"
case mcpServers = "MCP Servers"
case gateway = "Gateway"
case cron = "Cron"
case health = "Health"
case logs = "Logs"
case settings = "Settings"
@@ -16,12 +22,18 @@ enum SidebarSection: String, CaseIterable, Identifiable {
var icon: String {
switch self {
case .dashboard: return "gauge.with.dots.needle.33percent"
case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal"
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble"
case .memory: return "brain"
case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .mcpServers: return "puzzlepiece.extension"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
case .logs: return "doc.text"
case .settings: return "gearshape"
}
@@ -32,4 +44,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String?
var selectedProjectName: String?
}
+8 -2
View File
@@ -7,7 +7,13 @@ struct SidebarView: View {
@Bindable var coordinator = coordinator
List(selection: $coordinator.selectedSection) {
Section("Monitor") {
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
}
Section("Projects") {
ForEach([SidebarSection.projects]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
@@ -19,7 +25,7 @@ struct SidebarView: View {
}
}
Section("Manage") {
ForEach([SidebarSection.cron, .logs, .settings]) { section in
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+34
View File
@@ -54,6 +54,33 @@ final class MenuBarStatus {
timer = nil
}
func startHermes() {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = ["gateway", "start"]
process.standardOutput = Pipe()
process.standardError = Pipe()
try? process.run()
process.waitUntilExit()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.refresh()
}
}
func stopHermes() {
fileService.stopHermes()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.refresh()
}
}
func restartHermes() {
fileService.stopHermes()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.startHermes()
}
}
private func refresh() {
hermesRunning = fileService.isHermesRunning()
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
@@ -69,6 +96,13 @@ struct MenuBarMenu: View {
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
Divider()
Button("Start Hermes") { status.startHermes() }
.disabled(status.hermesRunning)
Button("Stop Hermes") { status.stopHermes() }
.disabled(!status.hermesRunning)
Button("Restart Hermes") { status.restartHermes() }
.disabled(!status.hermesRunning)
Divider()
Button("Open Dashboard") {
coordinator.selectedSection = .dashboard
NSApplication.shared.activate()