---
name: hermes-desktop-macos
description: Native macOS SSH workspace for Hermes Agent — sessions, terminal, files, usage, and skills over direct SSH
triggers:
- set up Hermes Desktop on my Mac
- connect Hermes Desktop to my SSH host
- configure Hermes Desktop SSH connection
- browse Hermes sessions in the desktop app
- edit Hermes memory files from Mac
- build Hermes Desktop from source
- troubleshoot Hermes Desktop SSH connection
- add a new connection profile in Hermes Desktop
---
# Hermes Desktop macOS
> Skill by [ara.so](https://ara.so) — Daily 2026 Skills collection.
Hermes Desktop is a native macOS app (Swift/SwiftUI, macOS 14+) that gives Hermes Agent users a first-class Mac workspace over direct SSH. It reads sessions from `~/.hermes/state.db`, edits canonical memory files (`USER.md`, `MEMORY.md`, `SOUL.md`), browses skills, shows token usage dashboards, and embeds a real SSH terminal — all without a gateway API, local file mirrors, or a remote helper service.
---
## Installation
### Option A: Download the release binary
```bash
# 1. Download from GitHub Releases
# 2. Unzip
unzip HermesDesktop.app.zip
# 3. Move to Applications
mv HermesDesktop.app /Applications/
# 4. Verify the bundle signature
codesign --verify --deep --strict /Applications/HermesDesktop.app
# 5. Check SHA-256 matches the release asset
shasum -a 256 HermesDesktop.app.zip
First-launch gatekeeper bypass (app is not yet notarized): Right-click → Open, or go to System Settings → Privacy & Security → Open Anyway.
Option B: Build from source
git clone https://github.com/dodo-reach/hermes-desktop
cd hermes-desktop
# Requires Xcode 15+ and macOS 14 SDK
./scripts/build-macos-app.sh
# The built app lands in the repo root or DerivedData — check script output
open HermesDesktop.app
Requirements
| Requirement | Detail |
|---|---|
| macOS | 14 Sonoma or newer |
| SSH auth | Key-based, no interactive prompts from this Mac |
| Host key | Already accepted once in Terminal |
| Remote | python3 available in the SSH environment |
| Hermes data | ~/.hermes/ present on the remote host |
Quick sanity check — if this works without prompts, the app will work:
ssh your-host echo ok
# → ok
Connecting to a Hermes Host
Using an SSH alias (recommended)
Add an entry to ~/.ssh/config on your Mac:
Host hermes-pi
HostName 192.168.1.42
User pi
IdentityFile ~/.ssh/id_ed25519
Host hermes-vps
HostName vps.example.com
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host hermes-local
HostName localhost
User alex
In the app: Connections → New Profile → SSH alias → hermes-pi. Leave Host/User/Port blank.
Using explicit host details
In the app:
- Host or IP:
vps.example.com - User:
deploy - Port:
2222
Testing the connection
Click Test before Use Host. The preflight check verifies:
- SSH target is reachable
- Authentication completes without prompts
python3exists in the remote SSH environment
App Sections
Overview
Displays on first connect:
- Remote
$HOMEpath - Hermes root (
~/.hermes) - Which canonical files are present
- Session source (
state.dbor.jsonlfallback)
Files — editing canonical Hermes files
The app provides conflict-aware editing for three files:
| File | Purpose |
|---|---|
~/.hermes/memories/USER.md |
User context fed to the agent |
~/.hermes/memories/MEMORY.md |
Agent working memory |
~/.hermes/SOUL.md |
Agent persona/soul definition |
Conflict protection flow:
- App records the remote file hash at load time
- Before saving, it re-fetches the remote hash
- If the file changed on the host since load → save is blocked, edits preserved
- Prompt: Reload from Remote → review diff → re-apply edits → save
Sessions
- Source:
~/.hermes/state.db(SQLite, canonical) - Fallback:
~/.hermes/sessions/*.jsonlif SQLite unavailable - Features: search, metadata display, remote deletion, refresh-on-entry
Usage
Aggregate token dashboard sourced from ~/.hermes/state.db:
- Total input / output tokens
- Top sessions by token count
- Per-model breakdowns
- Recent session trend
Skills
Recursive browser for ~/.hermes/skills/**/SKILL.md:
- Discovers all nested skill files
- Quick filter/search in the list
- Full markdown preview
Terminal
Embedded SSH terminal with tabs. Uses /usr/bin/ssh — the same binary as Terminal.app. Connects to the same host as the active profile.
SSH Configuration Patterns
Key-based auth setup (if not already done)
# Generate a key on your Mac if needed
ssh-keygen -t ed25519 -C "hermes-desktop" -f ~/.ssh/id_hermes
# Copy public key to the remote host
ssh-copy-id -i ~/.ssh/id_hermes.pub user@host
# Or manually:
cat ~/.ssh/id_hermes.pub | ssh user@host "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
# Test — must return without any prompt
ssh -i ~/.ssh/id_hermes user@host echo ok
SSH config for common topologies
# Tailscale (MagicDNS hostname)
Host hermes-tailscale
HostName my-machine.tail1234.ts.net
User alex
IdentityFile ~/.ssh/id_ed25519
# Jump host / bastion
Host hermes-behind-bastion
HostName 10.0.0.5
User ubuntu
ProxyJump bastion.example.com
IdentityFile ~/.ssh/id_ed25519
# Same Mac via localhost
Host hermes-local
HostName localhost
User alex
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking no
Note: Hermes Desktop uses standard
/usr/bin/ssh. If your setup requires the separatetailscale sshcommand, use a ProxyCommand instead:
Host hermes-tailscale-cmd
HostName my-machine
User alex
ProxyCommand tailscale ssh --nc %h %p
Building from Source — Key Files
hermes-desktop/
├── scripts/
│ └── build-macos-app.sh # Universal (arm64 + x86_64) build script
├── HermesDesktop/
│ ├── App/
│ │ └── HermesDesktopApp.swift # SwiftUI @main entry point
│ ├── Views/
│ │ ├── SessionsView.swift
│ │ ├── FilesView.swift
│ │ ├── UsageView.swift
│ │ ├── SkillsView.swift
│ │ └── TerminalView.swift
│ ├── Models/
│ │ └── ConnectionProfile.swift
│ └── SSH/
│ └── SSHClient.swift # Wraps /usr/bin/ssh process calls
└── HermesDesktop.xcodeproj
Build script usage
# Full universal build
./scripts/build-macos-app.sh
# Verify the result is a universal binary
lipo -info HermesDesktop.app/Contents/MacOS/HermesDesktop
# → Architectures in the fat file: arm64 x86_64
Xcode build (development)
# Debug build for current arch
xcodebuild -scheme HermesDesktop -configuration Debug build
# Run tests
xcodebuild -scheme HermesDesktop test
Swift Patterns Used in the Codebase
Running SSH commands (non-interactive)
import Foundation
func runSSHCommand(
alias: String,
command: String,
completion: @escaping (Result<String, Error>) -> Void
) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = [
"-o", "BatchMode=yes", // never prompt interactively
"-o", "ConnectTimeout=10",
alias,
command
]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
if proc.terminationStatus == 0 {
completion(.success(output))
} else {
completion(.failure(SSHError.nonZeroExit(output)))
}
}
do {
try process.run()
} catch {
completion(.failure(error))
}
}
// Usage
runSSHCommand(alias: "hermes-pi", command: "python3 --version") { result in
switch result {
case .success(let version): print("python3 ok:", version)
case .failure(let err): print("preflight failed:", err)
}
}
Reading a remote file with conflict detection
struct RemoteFile {
let content: String
let sha256: String // hash at load time
}
func fetchRemoteFile(
alias: String,
path: String,
completion: @escaping (Result<RemoteFile, Error>) -> Void
) {
// Fetch content and hash in one SSH call
let cmd = "python3 -c \""
+ "import hashlib, pathlib; "
+ "p = pathlib.Path('\(path)').expanduser(); "
+ "b = p.read_bytes(); "
+ "print(hashlib.sha256(b).hexdigest()); "
+ "print(b.decode())\""
runSSHCommand(alias: alias, command: cmd) { result in
switch result {
case .success(let output):
let lines = output.components(separatedBy: "\n")
guard let hash = lines.first else {
completion(.failure(RemoteFileError.parseError)); return
}
let content = lines.dropFirst().joined(separator: "\n")
completion(.success(RemoteFile(content: content, sha256: hash)))
case .failure(let err):
completion(.failure(err))
}
}
}
func saveRemoteFile(
alias: String,
path: String,
newContent: String,
originalHash: String,
completion: @escaping (Result<Void, Error>) -> Void
) {
// Check current remote hash before writing
let checkCmd = "python3 -c \""
+ "import hashlib, pathlib; "
+ "p = pathlib.Path('\(path)').expanduser(); "
+ "print(hashlib.sha256(p.read_bytes()).hexdigest())\""
runSSHCommand(alias: alias, command: checkCmd) { result in
switch result {
case .success(let currentHash):
if currentHash.trimmingCharacters(in: .whitespacesAndNewlines) != originalHash {
completion(.failure(RemoteFileError.conflict))
return
}
// Safe to write — pipe content via stdin
let writeCmd = "cat > \(path)"
// ... write newContent via stdin to the process
completion(.success(()))
case .failure(let err):
completion(.failure(err))
}
}
}
enum RemoteFileError: Error {
case parseError
case conflict // remote changed since load — prompt user to reload
}
SwiftUI connection profile model
import Foundation
struct ConnectionProfile: Identifiable, Codable {
var id: UUID = UUID()
var name: String
var sshAlias: String? // preferred: uses ~/.ssh/config
var host: String?
var user: String?
var port: Int = 22
/// The argument passed to /usr/bin/ssh
var sshTarget: String {
if let alias = sshAlias, !alias.isEmpty {
return alias
}
let u = user.map { "\($0)@" } ?? ""
let p = port != 22 ? " -p \(port)" : ""
return "\(u)\(host ?? "localhost")\(p)"
}
}
Troubleshooting
Test fails: "Authentication failed"
# Verify key auth works without prompts
ssh -o BatchMode=yes -o ConnectTimeout=10 your-host echo ok
# If it asks for a password, set up key-based auth:
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@host
# Check the SSH agent has the key loaded
ssh-add -l
ssh-add ~/.ssh/id_ed25519
Test fails: "python3 not found"
# On the remote host, check python3 path
which python3
# If it's in a non-standard location like /usr/local/bin, ensure PATH is set
# in ~/.bashrc or ~/.zshrc (non-interactive shells may have a minimal PATH)
# Quick fix: symlink to a standard location
sudo ln -s /usr/local/bin/python3 /usr/bin/python3
Test fails: "Host key verification failed"
# Accept the host key interactively first
ssh your-host
# Type 'yes' when prompted
# Or clear a stale entry
ssh-keygen -R your-host
ssh your-host # accept fresh key
Sessions view shows no sessions
The app tries ~/.hermes/state.db first. Check on the remote host:
ssh your-host "ls -lh ~/.hermes/state.db"
# If missing, check for jsonl fallback:
ssh your-host "ls ~/.hermes/sessions/*.jsonl | head -5"
Conflict error when saving a memory file
The remote file changed after you opened it. In the app:
- Click Reload from Remote
- The editor will show the current remote content
- Re-apply your edits manually
- Save again
Tailscale: SSH works with tailscale ssh but not ssh
Hermes Desktop uses /usr/bin/ssh. Configure a ProxyCommand in ~/.ssh/config:
Host hermes-tailscale
HostName my-machine
User alex
ProxyCommand /usr/local/bin/tailscale nc %h %p
Then use hermes-tailscale as the SSH alias in the app.
Monitor live network activity
# Watch what the app connects to
sudo nettop -p HermesDesktop
# Or use lsof to see open connections
lsof -i -n -P | grep HermesDesktop
Key Design Constraints
| Constraint | Reason |
|---|---|
| No gateway API | Remote host is the only source of truth |
| No local file mirror | Avoids stale state and sync conflicts |
| No remote helper service | Zero install footprint on the host |
Uses /usr/bin/ssh |
Same path as Terminal — no novel auth surface |
BatchMode=yes on all non-terminal SSH calls |
Prevents silent interactive prompt hangs |
| Conflict check before every save | Protects against agent overwriting newer remote state |