Compare commits

...

61 Commits

Author SHA1 Message Date
Alan Wizemann 0384c6ef17 chore: Bump version to 2.0.2 2026-04-20 15:46:07 -07:00
Alan Wizemann f36fb55ebe test(ssh): regression tests for ControlPath socket-limit invariants
Two tests pinning the invariants that were violated / introduced
by the #19 / PR #20 fix:

- controlDirPathFitsMacOSSocketLimit: asserts dir + '/' + 64-char
  %C hash + NUL <= 104 bytes. Would have caught the original
  Caches-based path landing at 105 bytes for users with longer
  $HOME strings.

- controlDirPathIsPerUser: asserts the path includes the current
  uid, pinning the per-user-isolation invariant against any future
  refactor that drops it (since /tmp is shared across all local
  users).

scarfTests was a stub before this — these are the suite's first
real tests.
2026-04-20 15:45:29 -07:00
Alan Wizemann 1823160546 fix(ssh): defensive ControlPath dir + sweep stale sockets
Layered hardening on top of the /tmp ControlPath move from #20:

- ensureControlDir uses POSIX mkdir(0700) + lstat instead of
  createDirectory + setAttributes. Closes the /tmp pre-creation
  TOCTOU: any local user can pre-create /tmp/scarf-ssh-<uid>, and
  the old code would silently fail to chmod a hostile dir back to
  0700 (since we wouldn't own it). Now we refuse to use a dir that
  isn't a real directory we own with mode 0700, and log via
  os.Logger.

- sweepStaleControlSockets removes ControlMaster socket files
  older than 30 minutes from controlDirPath() at app launch.
  Symmetric to sweepOrphanSnapshots — keeps /tmp/scarf-ssh-<uid>/
  from accumulating crashed-master / unclean-exit orphans
  indefinitely until reboot. The 30-min threshold (vs ControlPersist's
  10 min) ensures any concurrent Scarf instance's live sockets
  are untouched.
2026-04-20 15:45:20 -07:00
Alan Wizemann d2a447fcc4 docs: add GitHub wiki + scripts/wiki.sh helper with secret-scan
Public docs now live at https://github.com/awizemann/scarf/wiki (separate
git repo cloned to .wiki-worktree/, mirroring the .gh-pages-worktree/
pattern). Internal dev notes stay in scarf/docs/.

scripts/wiki.sh wraps pull/commit/push with a two-pass secret-scan: hard
patterns (token regexes + private-key headers + a user-maintained
scripts/wiki-blocklist.txt) abort with non-zero exit; soft assignment
patterns (api_key=…, password=…, token=…) warn and require --force-terms.

CLAUDE.md gains a Wiki section listing the update triggers (new feature,
new service, architecture change, Hermes version bump, full release,
keyboard/sidebar change) and the workflow. CONTRIBUTING.md points
external contributors at the wiki Edit button or a direct clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:32:47 -07:00
Alan Wizemann 76bfeb34d4 chore: Bump version to 2.0.1 2026-04-20 15:32:47 -07:00
Alan Wizemann 85a4ec0e14 Merge pull request #20 from aliatx2017/fix/controlpath-too-long
fix: ControlPath too long for Unix socket on macOS
2026-04-20 15:08:40 -07:00
Alan Wizemann 1453c7a841 Merge fix/issue-19-ssh-diagnostics into main — v2.0.1 hotfix
Closes #19 (remote SSH connections showed connected but every view
read as empty). Eight commits bring:
- Result-returning readers in HermesFileService that surface errors
  instead of silently returning nil
- HermesDataService.open records lastOpenError with humanized hints
- Dashboard orange banner when remote reads fail
- New Remote Diagnostics sheet (14-probe checklist, stethoscope icon)
- Yellow 'degraded' pill state for 'connected but can't read' case
- Auto-suggest remoteHome in Test Connection for systemd/Docker
  installs at /var/lib/hermes/.hermes etc.
- Log-noise suppression for expected 'No such file' reads
- Diagnostics script pipes via stdin to sh -s (not sh -c argv), so
  multi-line scripts run in one sh process with variable scope
- Pill UX: state-specific SF Symbol instead of dot, no custom
  background, centered via .principal
- README 'Remote setup requirements' + troubleshooting section

Investigation notes + deferred follow-ups recorded in the session
transcript. See releases/v2.0.1/RELEASE_NOTES.md for the full
user-facing breakdown.
2026-04-20 14:27:11 -07:00
Alan Wizemann bd21a539e6 docs: update v2.0.1 release notes for diagnostics fixes + pill UX
Reflect the three post-initial-commit fixes:
- log-noise suppression (skill.yaml / optional-file 'No such file'
  warnings no longer spam Console via the new Result-returning readers)
- diagnostics script now stdin-pipes to sh -s instead of sh -c <script>
  argv, so it runs as one sh process with variable scope preserved
- pill UX: replaced colored dot with state-specific SF Symbol
  (checkmark / stethoscope / arrows / triangle), removed custom
  background, kept .principal placement for centering

Also expanded the 'Known follow-ups' section so users know what's
explicitly deferred post-2.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:26:33 -07:00
Alan Wizemann d3055702ef fix: connection pill — revert to .principal, swap dot for state SF Symbol
Rolling back the .primaryAction placement (the pill shifted right and
lost its centered position in the toolbar). The "funny background with
shadow" visible in the toolbar is macOS's own .principal emphasis bezel
— not something Scarf draws, and not something we can cleanly hide
without disabling the toolbar surface itself. The native bezel is the
pill's frame; we just have to make the pill's interior read well inside
it.

Two changes to make the pill itself look like a toolbar tool inside
that bezel:

- Drop the colored dot, replace with a state-specific SF Symbol. The
  icon's shape signals clickability (looks like a tool button), and its
  color signals state (green/orange/yellow/red hierarchical). Less
  "status chip", more "toolbar button with status".
- Icons per state:
  - connected  → checkmark.circle.fill (click to re-probe)
  - degraded   → stethoscope (click to run diagnostics, matches the
                 stethoscope on the Manage Servers row)
  - idle       → arrow.triangle.2.circlepath (checking/retry)
  - error      → exclamationmark.triangle.fill (click for stderr)

Horizontal padding = 4 so the icon-and-label sit balanced inside the
bezel rather than pushed up against its edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:22:35 -07:00
Alan Wizemann ee1d705abc fix: move connection pill off .principal to drop the emphasis bezel
macOS applies a centered emphasis bezel (light capsule + drop shadow)
to ToolbarItem(placement: .principal) — visible in screenshots as a
doubly-framed "capsule behind the pill" look. The pill itself doesn't
own that background; the toolbar placement does.

.primaryAction (right side of the toolbar) has no decorative
background, so the pill renders as just the colored dot + label text
directly on the toolbar surface. Fits the intended minimal look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:54 -07:00
Alan Wizemann 8e3dafe4c6 fix: remove the pill's own capsule background
The toolbar item already draws its own bezel for the principal-placement
slot; painting a `Color.secondary.opacity(0.08)` capsule on top gave the
pill a doubly-framed look. Drop the pill's background + the padding that
was only there to fit inside the capsule. The dot + label now sit
directly on the toolbar's native surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:14:12 -07:00
Alan Wizemann c51241dc72 fix: diagnostics script — pipe to sh via stdin, not sh -c argv
The previous fix (direct ssh argv, bypassing transport.runProcess) got
us from 0/14 to 7/14, but \$H was empty everywhere it was referenced —
the user's 7/14 report showed:
- probe 4 (hermesHomeConfigured): PASS with empty detail
- probe 5 (hermesDirExists FAIL): "not a directory:" (empty after colon)
- probe 11 (sqlite3CanOpenStateDB FAIL): 'unable to open "/state.db"'

Root cause: `ssh host -- /bin/sh -c <script>` doesn't travel as three
argv entries to the remote. ssh concatenates them with single spaces
into one command string and sends that to the remote's LOGIN shell.
The login shell then runs `$LOGIN_SHELL -c "$string"`, and bash's
parser treats unquoted newlines inside `$string` as command separators.
So the first newline splits the script: `/bin/sh -c H="..."` becomes
one command (which runs in an ephemeral sh subprocess that exits
immediately), and every subsequent line runs in the login shell with
no \$H set.

TestConnectionProbe happens to still work because its downstream lines
don't depend on an assignment from the first line — but the diagnostic
script's \$H is used everywhere, so the entire script is effectively
running with \$H="".

Fix: pipe the script into `/bin/sh -s` on stdin via ssh's own stdin
channel. `sh -s` reads a shell program from stdin and executes it in
one process, variable scope preserved. Implementation uses
Process.standardInput with a Pipe, writing the script after proc.run()
and closing the write end so sh sees EOF. Same as
`cat script.sh | ssh host -- /bin/sh -s` from the command line.

Also: raw-output disclosure panel in the diagnostics sheet now shows
whenever ANY probe fails, not only when all fail. Partial failures are
the most common failure mode and the raw stdout is the only way to see
why a specific detail came back the way it did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:04:32 -07:00
Alan Wizemann ec03627bcd fix: diagnostics sheet — bypass transport.runProcess for shell script
First-run of diagnostics against a working Mardon returned 0/14 passing
with "(no output)" for every probe — including the trivial "emit
connectivity PASS" that the script emits unconditionally. That meant the
script wasn't executing as written; the parser saw `__END__` but no
probe lines.

Root cause: SSHTransport.runProcess wraps every argument through
`remotePathArg`, which is designed for PATHS (it rewrites `~/` to
`$HOME/` and double-quotes the result with backslash-escapes). Passing
a multi-line shell script with embedded `"$1"` / `"$2"` / `"$3"` and
`printf '\n'` escape sequences through that is corruption — the remote
sh -c receives a scrambled script and silently emits nothing.

TestConnectionProbe already works around this: it builds the ssh argv
directly (ssh host -- /bin/sh -c <script>) so the script travels as a
single opaque argv entry and ssh forwards it to the remote shell
unchanged.

Mirror that approach. RemoteDiagnosticsViewModel.execute now:
- For remote contexts: builds ssh argv directly (ControlMaster-aware,
  uses the same socket as SSHTransport so it's effectively free after
  the first connection), then passes /bin/sh -c <script> as argv.
- For local contexts: spawns /bin/sh -c <script> via Process directly.

Also surfaces raw stdout/stderr/exit-code in a disclosure panel at the
bottom of the sheet, visible only when ALL probes fail. Makes any
future transport-level breakage self-diagnosing: the user sees exactly
what the remote returned, not just "(no output)" rows.

Expose SSHTransport.controlDirPath (already static) as a public helper
so the diagnostics probe reuses the same ControlMaster socket as the
connection itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:56:09 -07:00
Alan Wizemann f8069a4481 fix: don't log 'No such file' as warnings on remote reads
The Result-returning readers I added for the v2.0.1 diagnostics surface
were logging EVERY failure, including routine "file doesn't exist" cases
— e.g. skill.yaml files under ~/.hermes/skills/*/ that are optional
metadata, gateway_state.json before Hermes has started, memories/USER.md
on fresh installs.

In practice this meant the Platforms view and similar feature loaders
that walk directories and read optional files now spam the Console with
warnings on every refresh. That's noisier than useful and actively hides
the signal (permission denied, connection failure, sqlite3 missing) we
added the logging to surface.

readFileDataResult now detects the "no such file" case via either:
- TransportError.fileIO(_, "No such file...") from SSHTransport
- NSCocoaErrorDomain code 260 (NSFileNoSuchFileError) from FileManager
- NSPOSIXErrorDomain code 2 (ENOENT)

and suppresses the warning log for those paths. The Result.failure is
still returned, so any caller that cares (Dashboard's banner, Remote
Diagnostics) can still distinguish missing from present-but-unreadable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:52:28 -07:00
Alan Wizemann 110170d6e9 fix: v2.0.1 — surface remote SSH file-access errors (closes #19)
Three users reported on day-one of v2.0 that SSH connections showed a
green "Connected" pill but every data view read as empty / "not running"
/ "not configured". The common thread across Docker, homelab VM, and
Ubuntu VPS setups: file-access failures on the remote that Scarf
silently swallowed into nil/empty defaults.

Stop swallowing errors
- HermesFileService gains Result-returning variants for the four
  dashboard-critical readers: loadConfigResult, loadGatewayStateResult,
  hermesPIDResult, plus readFileResult / readFileDataResult as
  primitives. Each logs os.Logger warnings on failure. Legacy nil-
  returning signatures remain as thin forwarders.
- HermesDataService.open records lastOpenError with humanized hints
  for the top three failure modes — sqlite3 not installed, permission
  denied, file not found. Each maps to concrete remediation (`apt
  install sqlite3`, "check file perms", "set Hermes data directory").

Dashboard surfaces the error
- DashboardViewModel collects errors from every loader into
  lastReadError, only on remote contexts (local skips the banner).
- DashboardView renders an orange banner above the stats with the
  specific error text, a copy-selectable detail, and a "Run
  Diagnostics…" button.

New Remote Diagnostics sheet (stethoscope icon)
- RemoteDiagnosticsViewModel runs 14 checks in one SSH round-trip via
  a pipe-delimited "KEY|STATUS|DETAIL" protocol. Covers: SSH
  connectivity, remote user/$HOME, Hermes dir existence + readability,
  config.yaml readability + actual read (distinct from just `test -e`
  which can't detect permission issues), state.db readability, sqlite3
  binary presence, sqlite3 open test, hermes binary on non-login AND
  login PATH, pgrep availability.
- Each probe row shows a targeted hint on fail (e.g. "check perms on
  ~/.hermes", "apt install sqlite3", "move PATH export from .bashrc
  to .zshenv"). A Copy Full Report button dumps plain-text output
  for GitHub issues.
- Accessible from Manage Servers (stethoscope button per row) and
  directly from the yellow pill.

Yellow "degraded" connection state
- ConnectionStatusViewModel.Status gains .degraded(reason:) between
  .connected and .error. After tier-1 `true` passes, the probe runs
  tier-2 `test -r $HOME/.hermes/config.yaml` in the same SSH round-
  trip. On tier-2 fail, pill is orange with "Connected — can't read
  Hermes state" tooltip.
- Clicking a degraded pill opens Remote Diagnostics directly. Exactly
  the symptom in #19 is now one click from a specific answer.

Auto-suggest remoteHome for non-default installs
- TestConnectionProbe.TestResult.success gains suggestedRemoteHome:
  String?. When state.db isn't found at the configured path, the
  probe also checks /var/lib/hermes/.hermes, /opt/hermes/.hermes,
  /home/hermes/.hermes, /root/.hermes — the common alternates for
  systemd services, Docker containers, and single-user VPSes — and
  surfaces the first hit as a "Use this" suggestion in Add Server.
- AddServerSheet relabels "Remote ~/.hermes override" to "Hermes data
  directory" with an explanation of when you'd use it.

README
- New "Remote setup requirements" subsection lists the four concrete
  prereqs (SSH, sqlite3, pgrep, read access to ~/.hermes).
- New "Troubleshooting remote connections" paragraph describes the
  diagnostics sheet and remoteHome auto-suggest for the two most
  common failure modes.

Releases
- releases/v2.0.1/RELEASE_NOTES.md for the GitHub release body.
- Ship via `./scripts/release.sh 2.0.1`.

Closes #19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:40:35 -07:00
Alex Maksimchuk 1293cfa23b fix: use short ControlPath to avoid Unix socket limit on macOS
The ControlMaster socket path ~/Library/Caches/scarf/ssh/%C can
exceed the 104-byte macOS Unix domain socket limit when the
username is long, causing ssh to silently exit 255 with
"unix_listener: path too long for Unix domain socket".

Switch to /tmp/scarf-ssh-<uid> which stays well within the limit.
2026-04-19 23:03:28 -05:00
Alan Wizemann ba8bf14ff0 chore: Bump version to 2.0.0 2026-04-19 13:07:49 -07:00
Alan Wizemann 4212200dca Merge remote-servers into main — v2.0 release
Brings multi-window + multi-server + remote-SSH support to main,
plus the full correctness/UX/concurrency polish pass.

Two commits land:
- 00ca722 feat: multi-window + remote SSH server support (Phases 0-4)
- 5920923 feat: v2.0 — correctness + UX polish on multi-server + remote SSH

See releases/v2.0.0/RELEASE_NOTES.md for the user-facing summary.
2026-04-19 13:07:22 -07:00
Alan Wizemann 5920923d92 feat: v2.0 — correctness + UX polish on multi-server + remote SSH
The multi-window / multi-server / remote-SSH work that landed in
00ca722 (feat: multi-window + remote SSH server support (Phases 0-4))
was feature-complete but accumulated rough edges during dogfooding
against a remote Mac mini. This commit finishes the 2.0 release:
correctness fixes on remote, a chat-view UX overhaul, and a Swift 6
complete-concurrency sweep across the service layer.

Correctness on remote
- Kill the WAL-error spam: snapshotSQLite now runs `PRAGMA
  journal_mode=DELETE` on the remote temp DB before scp, so the
  pulled file is self-contained. Open remote snapshots with
  `file:...?immutable=1` URI as defense-in-depth, and drop the
  pointless `PRAGMA journal_mode=WAL` from HermesDataService.open.
- loadSessionHistory and refreshMessages now force a fresh snapshot
  via refresh(), so resuming a session on a remote shows messages
  persisted since launch (previously stuck on the first snapshot).
- New SnapshotCoordinator actor dedupes concurrent snapshotSQLite
  calls per ServerID — Dashboard + Sessions + Activity no longer
  issue three parallel SSH backups for the same fetch.
- ACP cwd comes from the remote's $HOME (probed once, cached per
  server in UserHomeCache), not the local Mac's NSHomeDirectory().
- Typing into a blank Chat always creates a new session. The old
  auto-resume-most-recent fallback was picking up cron-spawned
  sessions that Hermes had already GC'd, producing silent prompt
  failures.
- handlePromptComplete surfaces non-success stopReasons ("refusal",
  "error", "max_tokens") as a system message so failed prompts no
  longer sit under a forever-spinning "Agent working…".

Chat UX
- Replace six racing onChange-driven scrollTo calls with
  `.defaultScrollAnchor(.bottom)` alone. Manual proxy.scrollTo
  against a LazyVStack that hadn't finished laying out was
  overshooting into whitespace. Layout-pass-integrated anchor
  behaves correctly at stream start and finish.
- Remove ContentUnavailableView swap in RichChatView — it tore down
  the whole ScrollView hierarchy on first message. Empty state now
  lives inside the scroll view.
- continueLastSession surfaces an acpError banner if open() fails,
  instead of silently returning.

Lifecycle hygiene
- ServerRegistry.removeServer closes the server's SSH ControlMaster
  (`ssh -O exit`), prunes its snapshot cache dir, and invalidates
  UserHomeCache for that ID. App launch sweeps orphan snapshot dirs
  whose UUIDs aren't in the registry anymore.
- NSWorkspace.activateFileViewerSelecting (backup-saved-to dialog)
  gated on !context.isRemote; remote surfaces the remote path in the
  saveMessage instead of silently no-op'ing on a nonexistent local
  path.

Swift 6 concurrency — 230 warnings → 1
- Mark ServerContext, HermesPathSet, ServerTransport (protocol),
  LocalTransport, SSHTransport, HermesFileService, and every value-
  type accessor as `nonisolated`. Prevents AppKit-import-driven
  MainActor inference from bleeding onto data-only types.
- Hand-written Codable conformances (vs. synthesized) for
  ACPRequest, ACPRawMessage, ACPError, GatewayState, PlatformState,
  HermesCronJob, CronSchedule, CronJobsFile, AuthFile, AuthEntry.
  Synthesized inits were inferred @MainActor by Swift 6's default-
  isolation rule; hand-written ones are explicitly nonisolated.
- Captured-var refactors in MCPServerEditorViewModel, PluginsView
  Model, LocalTransport.watchPaths. Thread.sleep → Task.sleep in
  TestConnectionProbe.
- Remaining warning is AnyCodable.value mutation in init(from:) —
  Any-typed storage can't be strictly Sendable; acknowledged via
  @unchecked Sendable.

ACP adapter upstream bug (not fixed here, but handled)
- Hermes's ACP adapter returns JSON-RPC success `{"result":{}}` for
  session/load on a missing session, logging the warning only to
  stderr. Scarf can't distinguish "loaded" from "silently missing"
  at that layer; the stopReason=refusal surfacing above catches the
  downstream symptom. Upstream issue worth filing.

Release docs
- releases/v2.0.0/RELEASE_NOTES.md with full user-facing breakdown.
- README.md "What's New" bumped to 2.0 with a multi-server section.
  Compatibility table adds v0.10.0 as verified.
- GitHub repo description updated (via `gh repo edit`) to call out
  multi-server + remote SSH.

35 files changed, +809/-350.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:02:40 -07:00
Alan Wizemann 00ca7229df feat: multi-window + remote SSH server support (Phases 0-4)
Adds the ability to manage multiple Hermes installations — local and
remote over SSH — from the same Scarf app, each in its own window.

Architecture:
- ServerContext value type carries per-server identity + paths through
  every VM and service. ContentView routes serverContext into each
  feature view's init; all 22 routed views thread it through to their
  @State VMs.
- ServerTransport protocol with LocalTransport (FileManager/Process/
  FSEvents) and SSHTransport (system ssh + scp + ControlMaster).
  Services were ported from direct Foundation I/O to transport-routed
  helpers so the same code runs against local or remote.
- WindowGroup(for: ServerID.self) gives each window its own
  AppCoordinator + HermesFileWatcher + ChatViewModel. File menu has
  Open Server commands with keyboard shortcuts (⌘1..⌘9). MenuBarExtra
  fans out per-server with start/stop/restart controls.
- ServerRegistry persists connections to ~/Library/Application
  Support/scarf/servers.json. Add Server sheet probes the remote with
  ssh -v to capture the full handshake on failure.
- Connection-status pill in remote-window toolbars with silent reconnect
  (3s retry on first failure, escalate to red after 2 consecutive),
  known-hosts-mismatch + ssh-add hint cards with copy buttons.

Concurrency / UX hardening (the parts learned the hard way during
dogfooding — captured in the feedback memory):
- ServerContext exposes context.readText / readData / writeText /
  fileExists / runHermes / openInLocalEditor as the canonical I/O
  surface. Every VM uses these; never raw FileManager / Process() /
  NSWorkspace.open with a Hermes path.
- SSHTransport.remotePathArg rewrites ~/foo to "$HOME/foo" so paths
  expand correctly inside the sh -c command we build (POSIX shells
  don't expand ~ inside any quotes).
- Heavy VM load() methods detach to a background task and commit
  results back via MainActor.run, so synchronous ssh round-trips don't
  beach-ball the UI. Applied to Dashboard, Memory, Settings,
  MCPServers, Cron, Plugins, Personalities, QuickCommands, Skills,
  Gateway, Health, CredentialPools.
- LoadingOverlay modifier shows a spinner over empty/stale section
  content during background reloads.
- enrichedShellEnv (zsh -l -i probe, up to 8s) is now warmed at app
  launch off-main so first MainActor caller doesn't block.
- Drop the file watcher's 5s heartbeat — FSEvents covers real changes
  and the heartbeat was triggering wasted reloads across every
  subscribing view.

Chat polish:
- ChatViewModel.hermesBinaryExists is a stored bool probed once at
  init, not a sync transport call evaluated on every body re-render.
- MessageGroupView identifies assistant bubbles by array offset rather
  than message.id, so the streaming → finalized id transition no
  longer destroys + recreates the bubble.
- Static scroll anchor in RichChatMessageList prevents two onChange
  handlers from racing on isWorking flips.

Branch state: feature complete, in active dogfooding. Plan + per-phase
status live at ~/.claude/plans/we-developed-an-application-harmonic-stroustrup.md;
the four hard-won transport/concurrency rules are saved in the
ServerContext-pattern feedback memory for future sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:42:17 -07:00
Alan Wizemann 679dedf132 fix: release script — push main + tag before gh release create
The script was creating the GitHub release before pushing main, which
caused gh to auto-create the v<VERSION> tag at the then-current origin
HEAD (one commit behind the bump, since main hadn't been pushed yet).
The subsequent `git push origin v<VERSION>` was then rejected as
non-fast-forward, leaving the remote tag pointing at the wrong commit.

Caught during v1.6.2. The remote tag for v1.6.2 was force-corrected to
12610fa (the bump commit); the release artifacts themselves were always
correct.

New order: push main → tag main locally → push tag → gh release create.
Gh will now find the tag already on origin and attach to the right
commit. Non-destructive: a retry-safe release can always be resumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:24:31 -07:00
Alan Wizemann 12610faba0 chore: Bump version to 1.6.2 2026-04-17 17:18:33 -07:00
Alan Wizemann 73b44202ba fix: release script preflight allows pre-written RELEASE_NOTES.md
CLAUDE.md's release-notes convention says "write them to
releases/v<version>/RELEASE_NOTES.md BEFORE running the script" — but
the script's git-clean preflight rejected any working-tree state
including that exact file as untracked. Chicken-and-egg: you couldn't
follow the documented flow.

Preflight now whitelists releases/v<VERSION>/RELEASE_NOTES.md as the one
allowed untracked path. Everything else still fails the check.

Caught while running v1.6.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:18:23 -07:00
Alan Wizemann eed55cbb0f chore: Ignore release-artifact binaries
Stops the release script's git-clean preflight from tripping on the
zips + appcast-entry.xml that every release run produces under
releases/v<VERSION>/. GitHub Releases hosts the actual downloads; there's
no reason to commit ~30 MB of binaries per release into git history.

RELEASE_NOTES.md stays tracked — it's committed as part of the version
bump by the release script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:16:10 -07:00
Alan Wizemann 14c97bee62 docs: CLAUDE.md — document the release flow + canonical prompts
Adds a Releases section so future Claude sessions (and teammates) don't
have to rediscover the release workflow. Documents:

- The single entry point: `./scripts/release.sh <ver> [--draft]`
- What the script does end-to-end
- The release notes convention (write them before running)
- A handful of canonical prompts the user can type
- Pointers to deeper prerequisite docs (README, script header)

Deliberately brief — detail lives in README and the personal auto-memory
at reference_release_process.md. CLAUDE.md's job here is just to make
the entry point discoverable on session start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:13:15 -07:00
Alan Wizemann 8d3fe70e2c fix: Chat tab false-positive "no credentials" warning before session pick
The orange "No AI provider credentials detected" banner was firing on the
Chat tab whenever no session was selected, even for users whose
credentials were configured and working. The banner only disappeared
when a session started — not because credentials were actually found,
but because the banner's `!hasActiveProcess` gate flipped to false once
ACP launched.

Root cause: `HermesFileService.hasAnyAICredential()` inspected only the
shell environment and `~/.hermes/.env`, while Hermes itself resolves
credentials from two additional places Scarf had never learned about:

  - `~/.hermes/auth.json` — the Credential Pools file written by the
    Configure → Credential Pools UI (the blessed v1.6 flow)
  - `~/.hermes/config.yaml` — embedded `api_key:` under auxiliary.<task>
    and delegation

The preflight now checks all four locations. For auth.json we parse the
JSON and look for any `credential_pool.<provider>[*].access_token` that
is non-empty. For config.yaml we line-scan for `api_key:` leaves with a
non-empty value, matching the defensive style of the existing .env
scanner (no YAML parser needed in a nonisolated function).

Also updated the banner subtitle to point users at Credential Pools
before .env, since the former is the blessed in-app flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:10:51 -07:00
Alan Wizemann da88c98c7a feat: release script builds Universal + ARM64 variants
Each release now produces two distribution zips:
- Scarf-vX.X.X-Universal.zip  (arm64 + x86_64, recommended)
- Scarf-vX.X.X-ARM64.zip      (arm64 only, ~14% smaller)

Both are independently archived, exported with Developer ID, notarized,
and stapled via a new build_variant helper. The appcast still points at
the Universal zip since it works on all supported macs; ARM64 is an
alternative manual download for Apple Silicon users who want the smaller
file.

README updated to list both variants.

Prompted by the v1.6.1 release shipping only Universal; the ARM64 zip
for v1.6.1 was produced ad-hoc and uploaded to the existing release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:17 -07:00
Alan Wizemann b7ad01f9da chore: Bump version to 1.6.1 2026-04-16 19:04:48 -07:00
Alan Wizemann 868e61979e chore: release script supports --draft + RELEASE_NOTES.md
Drafts skip the appcast push and main tag, so a draft release won't
show up in users' Sparkle update feed and v1.6.0 stays "latest" until
explicitly promoted. The signed appcast entry is saved to the release
dir for later manual promotion.

Also adds release notes file convention: releases/v<VERSION>/RELEASE_NOTES.md
is auto-included in the version-bump commit and used as the GitHub
release body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:04:27 -07:00
Alan Wizemann 9bdd928469 fix: release script — rename scarf.app → Scarf.app after export
Xcode exports the bundle as scarf.app because PRODUCT_NAME = $TARGET_NAME
and the target is lowercase "scarf". Users expect Scarf.app in their
/Applications folder. Renaming the bundle wrapper preserves the
signature (codesign signs contents, not the wrapper directory name).

Caught during a build+sign+verify dry run before the first notarized
release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:59:41 -07:00
Alan Wizemann 75e489e39c fix: chat works without a terminal hermes session; surface the real error when it doesn't
A fresh-install user reported Scarf chat only worked while `hermes chat`
was also running in Terminal. ACP connected successfully but sending a
message errored. `~/.hermes/logs/errors.log` showed the real cause:

  RuntimeError: No Anthropic credentials found. Set ANTHROPIC_TOKEN or
  ANTHROPIC_API_KEY, run 'claude setup-token', or authenticate with
  'claude /login'.

The terminal workaround masked the bug because the terminal-launched
`hermes` inherits the user's shell env (ANTHROPIC_* exports, Keychain
session) while a Finder/Dock-launched Scarf subprocess does not.
Scarf's previous PATH-only enrichment (commit b2a29ab) fixed binary
discovery but not credential propagation.

Five changes:

1. Propagate credential env vars from the login shell.
   HermesFileService.enrichedEnvironment() now harvests a conservative
   allowlist of AI-provider keys (ANTHROPIC_API_KEY/TOKEN/BASE_URL,
   OPENAI_*, OPENROUTER_*, GEMINI/GOOGLE/GROQ/MISTRAL/XAI API keys,
   CLAUDE_CODE_OAUTH_TOKEN) alongside PATH. Uses one `zsh` probe with
   null-delimited `printf` so values with newlines survive, cached for
   the process lifetime.

2. Two-attempt shell probe catches nvm/asdf/mise PATH.
   Previous `zsh -l` missed `.zshrc`-exported PATH (nvm). New probe
   first tries `zsh -l -i` (login + interactive, sources .zshrc) with
   prompt frameworks defanged (TERM=dumb, empty PS1/PROMPT,
   POWERLEVEL9K_INSTANT_PROMPT=off, STARSHIP_DISABLE=1,
   ZSH_DISABLE_COMPFIX=true) and a 5s timeout; falls back to `zsh -l`
   with 3s; finally to hardcoded defaults.

3. Resolve `hermes` binary across install locations.
   HermesPaths.hermesBinary is now computed, walking pipx
   (~/.local/bin), Apple Silicon brew (/opt/homebrew/bin), Intel brew
   / manual (/usr/local/bin), and ~/.hermes/bin. Returns the first
   executable match or the pipx default for "Expected at …"
   diagnostics. All 10+ callsites (ACPClient, scarfApp, Health /
   Gateway / Tools / Sessions / QuickCommands / Personalities /
   Settings / WhatsAppSetup / OAuthFlow / CredentialPools
   ViewModels) auto-migrate with zero edits.
   HermesFileService.hermesBinaryPath() shares the same candidate
   list as the source of truth.

4. Surface the real failure in the chat UI.
   ACPClient keeps a 50-line ring buffer of subprocess stderr
   (previously only sent to os_log). New ACPErrorHint.classify pattern-
   matches the common fresh-install failures — "No credentials found",
   "No such file or directory: 'npx'", rate-limit — and returns a short
   human hint. ChatView gains an errorBanner between toolbar and chat
   area showing the hint + raw message + a "Show details" disclosure
   with the stderr tail in a selectable monospaced view, plus a
   clipboard-copy button.

5. Preflight credential check.
   HermesFileService.hasAnyAICredential() scans the enriched env and
   ~/.hermes/.env for any known provider key. ChatViewModel exposes
   `missingCredentials`; the banner becomes a pre-emptive warning
   ("No AI provider credentials detected — add ANTHROPIC_API_KEY to
   ~/.hermes/.env or your shell profile") before the user even hits
   Send. HermesFileWatcher already watches ~/.hermes/.env, so edits
   re-trigger preflight automatically.

Incidental cleanup: recordACPFailure(_:client:context:) folds the
per-site `logger.error` calls, removing three `_ = msg` suppressions.
Dead `enrichedPath` alias removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:49:44 -07:00
Alan Wizemann 41ea3aeb83 feat: Sparkle auto-updates + Developer ID notarization pipeline
Adds Sparkle 2 auto-updates and a local release script that produces
signed, notarized, stapled builds for GitHub distribution. App Store
submission was rejected because Scarf spawns the user-installed hermes
binary and reads ~/.hermes/ directly — both forbidden by App Sandbox —
so we commit to the GitHub-release path properly.

- Sparkle SPM dep wired into the app target (link-only; hardened-runtime
  entitlement disable-library-validation lets Sparkle load at runtime).
- Tracked Info.plist with SUFeedURL, SUPublicEDKey, and daily check
  interval; replaces the auto-generated plist so Sparkle keys live in
  version control rather than pbxproj INFOPLIST_KEY_* noise.
- UpdaterService wraps SPUStandardUpdaterController and is injected via
  .environment(). Menu bar, standard app menu (CommandGroup after
  .appInfo), and a new Updates section in Settings → General each call
  updater.checkForUpdates().
- scripts/release.sh runs the full pipeline: version bump → universal
  archive → Developer ID export → notarytool submit (keychain profile
  scarf-notary) → staple → appcast EdDSA sign → gh-pages push → gh
  release → tag. scripts/ExportOptions.plist pins manual Developer ID
  signing for team 3Q6X2L86C4.
- README: removes the right-click-Open workaround (notarized builds
  don't need it), notes Sparkle, adds a Releases section describing
  the pipeline and signing prerequisites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:42:20 -07:00
Alan Wizemann eb39dcfa61 docs: Restructure README + add v1.6.0 release binaries
Reorganize the Features section to match the app's sidebar (Monitor, Interact,
Configure, Manage, Project Dashboards, System) so readers find features the
same way they find them in the app. Add a "What's New in 1.6" callout with
links to the release notes.

Binaries: ARM64 (15 MB) and Universal (19 MB). Both signed with the Apple
Development identity (Team 3Q6X2L86C4). Universal contains both arm64 and
x86_64 slices verified with lipo.
2026-04-16 15:51:28 -07:00
Alan Wizemann 93ee194ba0 chore: Bump version to 1.6.0 2026-04-16 15:39:41 -07:00
Alan Wizemann b6d9113579 feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar
Major expansion of Scarf's Hermes platform coverage. Settings is now a 10-tab
layout exposing ~60 previously hidden config fields. A new "Configure" sidebar
section groups per-platform setup, personality management, quick commands,
credential pools, plugins, webhooks, and profile switching.

## Highlights

- **Platforms feature** — Native GUI setup for all 13 messaging platforms
  (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost,
  Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write
  credentials to ~/.hermes/.env and behavior toggles to ~/.hermes/config.yaml.
  WhatsApp and Signal use an inline SwiftTerm terminal for QR/link pairing.

- **Credential Pools** — Provider-aware add/remove with proper type handling.
  OAuth flow uses Process + pipes to extract the authorization URL, open the
  browser explicitly, and accept the code via a form field. Fixes the Anthropic
  OAuth failure where the code had nowhere to be entered.

- **Model Picker** — Hierarchical provider -> model picker backed by
  ~/.hermes/models_dev_cache.json (111 providers, every major model). Used in
  Settings -> General and Delegation. "Custom..." escape hatch for unlisted IDs.

- **Settings as tabs** — 10 tabs (General, Display, Agent, Terminal, Browser,
  Voice, Memory, Aux Models, Security, Advanced). HermesConfig grew from 32 to
  ~90 fields via grouped sub-structs. All new fields round-trip through
  `hermes config set`.

- **Extended existing features** — Cron (create/edit/pause/resume/run-now/
  delete), Skills (Browse Hub + Updates tabs), Health (run `hermes dump` and
  `hermes debug share` with confirmation dialog), Sessions (rename/delete/
  export/export-all).

## Bug fixes

- Tools platform picker showed only CLI (was reading a nonexistent
  `platform_toolsets:` YAML section). Now enumerates KnownPlatforms.all with
  live connectivity dots from gateway_state.json.
- Credentials add with --api-key was triggering OAuth for providers like
  Anthropic because --type was missing. Now always passes --type api-key.
- Remove-by-index used 0-based indexing; hermes CLI expects 1-based. Fixed.
- Various CLI parser fragility issues (plugins, profiles, skills hub, webhooks)
  replaced with structured file reads or proper box-drawn table parsers.

## New core services

- HermesEnvService — reads/writes ~/.hermes/.env atomically, preserves
  comments, commented-out keys get enabled in-place on save, values with
  spaces/specials get quoted, unset commented out (non-destructive).
- ModelCatalogService — decodes the models.dev cache into typed providers and
  models with context/cost/release-date metadata.
- OAuthFlowController — manages the OAuth Process subprocess: extracts the
  auth URL via regex, opens the browser, pipes the code back via stdin,
  detects success/failure markers in output.

## New sidebar structure

Monitor / Projects / Interact / **Configure (new)** / Manage

The Configure section gathers the setup-style features that used to require
the CLI: Platforms, Personalities, Quick Commands, Credential Pools, Plugins,
Webhooks, Profiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:39:07 -07:00
Alan Wizemann b2a29ab68d fix: MCP test failures hidden as success; brew/nvm binaries not on PATH
Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.

1. MCP test reported success even when the connection failed.
   `hermes mcp test <server>` exits 0 even when the inner connection
   fails — it prints the error to stdout instead. Scarf trusted the
   exit code and rendered a green checkmark while the output said
   "✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
   Fix: also scan output for ✗, "Connection failed", "No such file or
   directory", and "Error:" markers.

2. .app launches start with a minimal PATH that excludes Homebrew.
   When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
   `/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
   /usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
   can't find npx/node/python when spawning MCP server subprocesses.
   Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
   $PATH'`, cache it on HermesFileService, and inject it into both
   `runHermesCLI` and the ACP subprocess. Falls back to a sane default
   covering both Apple Silicon and Intel Homebrew if zsh query fails.

Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
2026-04-16 07:51:32 -07:00
Alan Wizemann 117a0ee9dd fix: MCP Servers — preserve all entries when patching config.yaml
Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.

Three root causes in HermesFileService MCP YAML patching:

1. extractMCPBlock extended through trailing comments to EOF when
   mcp_servers was the last top-level key in config.yaml. Trailing
   comments became part of the "block", so subsequent inserts landed
   at end-of-file rather than inside the entry.

2. patchMCPServerField's entry boundary similarly absorbed trailing
   blanks/comments, making the entry "own" everything until the next
   sibling — or until EOF for the last entry.

3. yamlScalar did not quote values starting with YAML reserved
   indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
   "@modelcontextprotocol/server-fetch" were written bare, producing
   invalid YAML that broke subsequent reads/writes.

Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.

Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.

Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
2026-04-16 07:38:49 -07:00
Alan Wizemann 61d59ba0e4 chore: Re-sign 1.5.6 release binaries
The first 1.5.6 zips contained `linker-signed` bundles with no Sealed
Resources, plus a stray nested scarf.app from a case-insensitive cp.
macOS Gatekeeper rejected the ARM64 download as "damaged"; the
Universal one ran only because the user had already trusted it.

Now both bundles are properly ad-hoc-signed (`Sealed Resources
version=2`) with the hardened runtime preserved. Sizes dropped
significantly (Universal 33MB→16MB, ARM64 27MB→13MB) because the
nested junk is gone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:13:55 -07:00
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
175 changed files with 22230 additions and 1213 deletions
+10
View File
@@ -1,5 +1,7 @@
# Xcode # Xcode
build/ build/
.gh-pages-worktree/
.wiki-worktree/
DerivedData/ DerivedData/
*.pbxuser *.pbxuser
!default.pbxuser !default.pbxuser
@@ -46,3 +48,11 @@ scarf/standards/backups/
# Scarf project dashboards (user-specific) # Scarf project dashboards (user-specific)
.scarf/ .scarf/
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
releases/v*/*.zip
releases/v*/appcast-entry.xml
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
scripts/wiki-blocklist.txt
+47
View File
@@ -38,3 +38,50 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
```bash ```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
``` ```
## Releases
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
```bash
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
```
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
**Canonical prompts (any of these trigger the flow):**
- "Release v1.6.2" — full release
- "Release v1.6.2 as draft" — draft mode
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
## Wiki
Public documentation lives in the GitHub wiki at https://github.com/awizemann/scarf/wiki. The wiki is a separate git repo cloned to `.wiki-worktree/` in the repo root (gitignored, sibling to `.gh-pages-worktree/`). Internal dev notes stay in `scarf/docs/`; the wiki is for public-facing reference.
**Update the wiki when:**
- A new feature module is added under `scarf/scarf/scarf/Features/` → extend the relevant User Guide page.
- A new core service is added under `Core/Services/` → extend `Core-Services.md`.
- Architecture changes (AppCoordinator, transport, MVVM-F rule, sandbox) → `Architecture-Overview.md` + the specific sub-page.
- Hermes version bumps in this file → `Hermes-Version-Compatibility.md`.
- `scripts/release.sh` completes a full (non-draft) release → bump latest-version on `Home.md` + append to `Release-Notes-Index.md`.
- Keyboard shortcut or sidebar section changes → `Keyboard-Shortcuts.md` / `Sidebar-and-Navigation.md`.
**Skip for:** bug fixes with no user-observable change, pure refactors, typos, test-only changes, internal cleanups.
```bash
./scripts/wiki.sh pull # always first
# edit .wiki-worktree/*.md with normal tools
./scripts/wiki.sh commit "docs: describe X" # runs secret-scan
./scripts/wiki.sh push # runs secret-scan again, then push
```
**Never** commit API keys, tokens, `.env` files, private keys, or real hostnames/IPs to the wiki. The script's two-pass secret-scan blocks common token patterns and a user-maintained blocklist at `scripts/wiki-blocklist.txt` (gitignored). Do not bypass without explicit approval. Full workflow on the wiki itself at `.wiki-worktree/Wiki-Maintenance.md`.
## 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.
+4
View File
@@ -33,6 +33,10 @@ Rules:
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception. - The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods. - Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
## Documentation
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
## Reporting Issues ## Reporting Issues
Open an issue with: Open an issue with:
+142 -26
View File
@@ -10,44 +10,119 @@
</p> </p>
<p align="center"> <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/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br><br> <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> <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> </p>
## What's New in 2.0
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
See the full [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0).
### Previously, in 1.6
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
- **Configure sidebar** — Personalities, Quick Commands, Plugins, Webhooks, Profiles
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
## Multi-server, one window per server
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…****Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
### Remote setup requirements
The remote host must have:
1. **SSH access** — key-based auth via your local ssh-agent. Scarf never prompts for passphrases; run `ssh-add` once in Terminal before connecting.
2. **`sqlite3`** on the remote `$PATH` — needed for the atomic DB snapshots. Install on the remote with `apt install sqlite3` (Ubuntu/Debian), `yum install sqlite` (RHEL/Fedora), or `apk add sqlite` (Alpine).
3. **`pgrep`** on the remote `$PATH` — used by the Dashboard "is Hermes running" check. Standard on every distro; install `procps` if missing.
4. **`~/.hermes/` readable by the SSH user**. When Hermes runs as a separate user (systemd service, Docker container), the SSH user needs read access to `config.yaml` and `state.db`. Either (a) SSH as the Hermes user, (b) `chmod` Hermes's home to be group-readable and add your SSH user to that group, or (c) set the **Hermes data directory** field when adding the server to point at the right location (e.g. `/var/lib/hermes/.hermes`).
### Troubleshooting remote connections
If the connection pill is green but the Dashboard shows "Stopped", "unknown", or empty values, the SSH user can't read the Hermes state files. Open **Manage Servers → 🩺 Run Diagnostics** (or click the yellow "Can't read Hermes state" pill in the toolbar). The diagnostics sheet runs fourteen checks in one SSH session — connectivity, `sqlite3` presence, read access to `config.yaml` and `state.db`, the effective non-login `$PATH` — and tells you exactly which one fails and why, with remediation hints for each. Use the **Copy Full Report** button to paste the full output into a bug report.
For the common "Hermes isn't at the default path" case (systemd services, Docker), **Test Connection** in the Add Server sheet now probes `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, and `/root/.hermes` when it can't find `state.db` at `~/.hermes/`, and offers a one-click fill if it finds any of them.
## Features ## Features
- **Dashboard** — System health, token usage, recent sessions with live refresh Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export ### Monitor
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls - **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher 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)
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher - **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
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
### Interact
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
### Configure *(new in 1.6)*
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
### Manage
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
- **Log Viewer** — Real-time log tailing with level filtering and text search - **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically - **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
- **Settings** — Structured config editor for all Hermes settings - **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
### Project Dashboards
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
### System
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements ## Requirements
- macOS 26.2+ - macOS 14.6+ (Sonoma)
- Xcode 26.3+ - Xcode 16.0+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` - [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.9.0+ recommended for full feature support)
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server.
### Compatibility ### Compatibility
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against: Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
| Hermes Version | Status | | Hermes Version | Status |
|----------------|--------| |----------------|--------|
| v0.6.0 (2026-03-30) | Verified | | v0.6.0 (2026-03-30) | Verified |
| v0.6.0 (2026-03-31, latest) | Verified | | v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13) | Verified |
| v0.10.0 (2026-04-18) | Verified (recommended for full 2.0 feature support) |
Scarf 2.0 targets Hermes v0.10.0 for the ACP session/fork/list/resume capabilities used by remote chat. Earlier Hermes versions remain supported for monitoring, sessions, and file-based features; ACP-specific behavior may gracefully degrade on older agents.
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
@@ -55,11 +130,15 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
### Pre-built Binary (no Xcode required) ### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases): Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip` - `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
2. Unzip and drag **Scarf.app** to Applications - `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
1. Unzip and drag **Scarf.app** to Applications
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
### Build from Source ### Build from Source
@@ -90,10 +169,11 @@ scarf/
Sessions/ Conversation browser with rename, delete, export Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector Activity/ Tool execution feed with inspector
Projects/ Agent-generated project dashboards with widget rendering Projects/ Agent-generated project dashboards with widget rendering
Chat/ Embedded terminal via SwiftTerm with voice controls Chat/ Rich ACP chat and embedded terminal with voice controls
Memory/ Memory viewer and editor Memory/ Memory viewer and editor
Skills/ Skill browser by category Skills/ Skill browser by category
Tools/ Toolset management per platform Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer Cron/ Scheduled job viewer
Logs/ Real-time log viewer Logs/ Real-time log viewer
@@ -114,11 +194,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `logs/*.log` | Text | Read-only | | `logs/*.log` | Text | Read-only |
| `gateway_state.json` | JSON | Read-only | | `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only | | `skills/` | Directory tree | Read-only |
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
| `hermes chat` | Terminal subprocess | Interactive | | `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable | | `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export | | `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart | | `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke | | `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/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write | | `scarf/projects.json` | JSON (registry) | Read/Write |
@@ -129,6 +212,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
| Package | Purpose | | Package | Purpose |
|---------|---------| |---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature | | [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching. Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
@@ -136,7 +220,7 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data. Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation. The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync. Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
@@ -144,7 +228,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an
## Project Dashboards ## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically. Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build ### What You Can Build
@@ -153,6 +237,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts - **Deployment monitors** — deploy history tables, uptime stats, error rate charts
- **Research dashboards** — experiment results, key findings, paper status checklists - **Research dashboards** — experiment results, key findings, paper status checklists
- **Agent activity views** — cron job results, content generation stats, task completion rates - **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 - **Any project status** — if your agent can measure it, Scarf can display it
### Quick Start ### Quick Start
@@ -227,6 +312,23 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
| `table` | Data table with headers | `columns`, `rows` | | `table` | Data table with headers | `columns`, `rows` |
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) | | `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) | | `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 **Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
@@ -236,7 +338,7 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context: The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered. > Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time. Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
@@ -260,6 +362,20 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
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. 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.
## Releases
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
Signing prerequisites (one-time):
- `Developer ID Application` certificate in the login Keychain
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
## Contributing ## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR. Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
## What's New in 1.6.1
### Auto-updates
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
### Notarized & Developer ID signed
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
### Fixes
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
### Under the hood
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
---
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
+13
View File
@@ -0,0 +1,13 @@
## What's New in 1.6.2
### Fixes
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
### Polish
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
---
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
+58
View File
@@ -0,0 +1,58 @@
## What's New in 2.0
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
### Multi-server
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
### Remote over SSH
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
### Chat UX overhaul
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
### Correctness — remote SQLite
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
### Under the hood
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()``LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
- ACP cwd now comes from the *remote* `$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
### Compatibility
Hermes v0.10.0 is now verified alongside v0.6v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
### Migration from 1.6.x
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
- `servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
### Known limitations
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
+68
View File
@@ -0,0 +1,68 @@
## What's New in 2.0.1
Hotfix for [#19](https://github.com/awizemann/scarf/issues/19) and the related reports from the first day of v2.0: users' remote SSH connections would show a green "Connected" pill but every view (Dashboard, Sessions, Activity, Chat) read as empty / "not running" / "not configured". Three distinct environments reported it — Docker Hermes on a LAN, homelab VM over Tailscale, Ubuntu VPS — and every one was a silent file-access failure on the remote that Scarf wasn't surfacing.
### Errors no longer disappear
Every remote read (`config.yaml`, `gateway_state.json`, `state.db`, `pgrep`) used to silently substitute an empty value on *any* failure — permission denied, missing file, `sqlite3` not installed, connection drop — they all looked identical to the UI. Now:
- Each failure logs a specific warning via `os.Logger` (visible in Console.app under subsystem `com.scarf`).
- The Dashboard shows an orange banner above the stats with the exact error (e.g. "Permission denied reading `~/.hermes/state.db`") and a **Run Diagnostics…** button.
- `HermesDataService` exposes a `lastOpenError` so views can explain *why* state.db couldn't be opened, rather than just rendering zeros.
- Routine "file doesn't exist" cases (optional `skill.yaml` metadata, `gateway_state.json` before Hermes starts, `memories/USER.md` on fresh installs) are detected and **not** logged as warnings — only real errors (permission denied, connection drops, `sqlite3` missing) hit the log. Prevents Console from filling with false-positive noise when directory walks encounter optional files.
### New Remote Diagnostics sheet
Accessible from **Manage Servers → 🩺** per-server button, or by clicking the orange connection pill when Scarf can see the server but can't read Hermes state. Runs fourteen checks in a single SSH session and shows pass/fail for each, plus a targeted hint per failure:
- SSH connectivity and auth
- Remote user identity and `$HOME` resolution
- `~/.hermes` directory existence and readability
- `config.yaml` readable (existence *and* actual read access — the old probe only checked existence)
- `state.db` readable
- `sqlite3` installed on the remote (required for the atomic snapshot Scarf pulls)
- `sqlite3` can actually open `state.db`
- `hermes` binary on the non-login `$PATH` (what runtime uses)
- `hermes` binary on the login `$PATH` (what the Test Connection probe uses)
- `pgrep` available (for the "is Hermes running" check)
One **Copy Full Report** button dumps every check as plain text for bug reports, and a raw-output disclosure panel shows the exact stdout/stderr the remote returned whenever any probe fails — so transport-level problems are self-diagnosing.
The diagnostics script is piped to `/bin/sh -s` on stdin rather than passed as `sh -c <script>` argv. The latter was getting split line-by-line by the remote's login shell (newlines parsed as command separators), which stranded variables set on line 1 in an ephemeral `sh` subprocess that exited before line 2 could use them. Stdin-piping runs the whole script in one `sh` process with variable scope preserved.
### Connection pill gains a "degraded" state
The pill used to be green as long as SSH connected; now after connectivity passes it runs a second-tier check (`test -r $HOME/.hermes/config.yaml`). If that fails, the pill turns **orange** with "Connected — can't read Hermes state" and clicking it opens Remote Diagnostics directly. This is the exact symptom mode in #19, and it's now one click away from a specific answer.
The pill's visual also got a pass: the colored dot is replaced with a state-specific SF Symbol (`checkmark.circle.fill` / `stethoscope` / `arrow.triangle.2.circlepath` / `exclamationmark.triangle.fill`), which reads more like a clickable toolbar tool and doubles as the status signal. No custom pill background anymore — the toolbar's native `.principal` bezel is the frame.
### Auto-suggest the correct `remoteHome` during Add Server
When Test Connection can't find `state.db` at the configured (or default) path, it now also probes the common alternate locations — `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, `/root/.hermes` — and offers a one-click "Use this" fill if it finds one. Removes the need to know that systemd-installed Hermes lives at `/var/lib/hermes/.hermes` by convention.
### Clearer copy for the `remoteHome` field
The Add Server sheet field is now labeled "Hermes data directory" with a description explaining when you'd override it (systemd service installs, Docker sidecars) and noting that Test Connection auto-suggests.
### README has a new "Remote setup requirements" section
Four concrete prerequisites (SSH, `sqlite3`, `pgrep`, read access to `~/.hermes`) and a troubleshooting paragraph pointing at Remote Diagnostics.
### Migrating from 2.0.0
Sparkle will offer the update automatically. Settings and server list are preserved verbatim — this is purely additive (new diagnostics surface, new error banners, auto-suggest in Test Connection). If you were affected by #19, run Remote Diagnostics after updating; the sheet should pinpoint the specific file access issue and suggest a fix.
### Under the hood
- New types: `RemoteDiagnosticsViewModel`, `RemoteDiagnosticsView`. Both are local to Scarf; no new transport protocol.
- `HermesFileService` gains `loadConfigResult()`, `loadGatewayStateResult()`, `hermesPIDResult()`, `readFileResult()`, `readFileDataResult()` — Result-returning variants that preserve the error. Legacy `loadConfig()` etc. still exist as thin forwarders for callers that don't need diagnostics.
- `HermesDataService.open()` records `lastOpenError` with humanized hints for "sqlite3 not installed", "permission denied", and "file not found" — the three failure modes that produce 90% of issue #19 symptoms.
- `ConnectionStatusViewModel` status enum gains `.degraded(reason:)` between `.connected` and `.error`.
- `TestConnectionProbe` result enum gains `suggestedRemoteHome: String?` carrying any alternate-location hit.
### Known follow-ups (not in 2.0.1)
- `TestConnectionProbe` uses a direct-argv ssh invocation that's functionally correct but fragile (works by accident when split across the login shell). Should be ported to the stdin-pipe pattern the diagnostics sheet now uses.
- Remaining `try?`-swallowed read paths beyond the four Dashboard-surfacing ones — Cron, Memory, Skills, MCP Servers, Platforms still silently render empty on read errors. Same fix pattern applies, low priority.
- `hermesBinaryHint` is only populated when the user clicks Test Connection; if they skip it, ACP chat and CLI calls fall back to bare `hermes` which requires it on the non-interactive PATH (rarely true for `~/.local/bin` installs). The connection-pill's second-tier probe could auto-populate this.
- Docker-host support: when users SSH to a Docker host, `pgrep` and `~/.hermes/` on the host don't see what's inside the container. Needs a `docker exec` wrapping option per server.
+41
View File
@@ -0,0 +1,41 @@
## What's New in 2.0.2
The actual root cause of [#19](https://github.com/awizemann/scarf/issues/19), found and patched by Scarf's first external contributor. v2.0.1 added the diagnostics UI assuming file-perm root cause; v2.0.2 fixes the underlying bug for everyone, regardless of perms.
### macOS Unix domain socket path limit (the real #19)
OpenSSH's ControlMaster multiplexes our bursty stat/cat/cp traffic over one TCP session per host. The socket path is bound by `bind(2)` to a Unix domain socket — and macOS' `sun_path` is **104 bytes including the NUL terminator**.
Scarf's old socket path was `~/Library/Caches/scarf/ssh/<%C>` where `%C` is OpenSSH's 64-char SHA1 hash of `(local user, host, port, remote user)`. For a username like `alex.maksimchuk`, the full path landed at **105 bytes** — one byte over the limit. ssh exited 255 with `unix_listener: path "..." too long for Unix domain socket`. Our `LogLevel=QUIET` flag (set so ACP's line-delimited JSON stays binary-clean) suppressed the diagnostic, and the user just saw "Remote command exited 255" — which the UI rendered as the silent empty-data state every reporter in #19 described.
The fix is to use a much shorter path:
```swift
"/tmp/scarf-ssh-\(getuid())" // ~17 bytes + 64 hash + sep + NUL = ~83 bytes
```
Per-user uid suffix keeps two local users' sockets from colliding in the shared `/tmp`, and 0700 perms on the dir keep them inaccessible to other users.
**Massive thanks to Alex Maksimchuk ([@aliatx2017](https://github.com/aliatx2017)) — Scarf's first external PR contributor — for diagnosing and patching this in [#20](https://github.com/awizemann/scarf/pull/20).** That diagnosis only happened because Alex bothered to read the codebase, reproduce against multiple usernames including a Termux/Android instance, and walk back from the cryptic exit code to the actual `bind()` failure. This release wouldn't exist without that work.
### Hardening on top of the fix
Three additions on top of Alex's patch, layered in via separate commits to keep the original change reviewable:
- **Defensive ownership check on the socket dir.** `/tmp` is world-writable, so a malicious local user could pre-create `/tmp/scarf-ssh-<uid>` and trick Scarf into using a hostile directory (we'd silently fail to chmod it back to 0700, since we wouldn't own it). `ensureControlDir` now uses POSIX `mkdir(0700)` (atomic, sets perms at create time) and on `EEXIST` runs `lstat` to verify the entry is a directory we own with mode 0700 — symlink → refuse, wrong owner → refuse + log to `os.Logger`, wrong mode → repair. Closes the `/tmp` pre-creation hole that's the standard concern for any per-user `/tmp` path.
- **Launch-time sweep of stale sockets.** `ServerRegistry.sweepOrphanCaches` already prunes orphaned snapshot directories on launch; it now also removes ControlMaster socket files older than 30 minutes. Socket basenames are `%C` hashes (not ServerIDs), so we can't keep "still registered" sockets the way the snapshot sweep does — but `ControlPersist` is 600s, so anything older than 30 minutes is guaranteed to be a dead orphan from a crashed master, an unclean app exit, or a server removed while another Scarf instance was holding the dir. Keeps `/tmp/scarf-ssh-<uid>/` from accumulating indefinitely until reboot, while leaving any concurrent Scarf instance's live sockets untouched.
- **Regression test for the path-length invariant.** `scarfTests` was a stub — it now has two tests: one asserting `controlDirPath().utf8.count + 1 + 64 + 1 ≤ 104` (would have caught the original #19 bug in CI), one asserting the path includes the current uid (pins the per-user-isolation invariant against a future "simplification" that drops it).
### v2.0.1 diagnostics work is still useful
The diagnostics sheet, orange "degraded" pill, dashboard error banner, and `remoteHome` auto-suggest from v2.0.1 all still ship — they just turn out not to have been the right diagnosis for the original three reporters. They remain valuable for the *other* connection-failure modes they were designed to surface (missing `sqlite3` on the remote, real permission errors, container/host visibility gaps, custom Hermes data directories). If you upgrade to v2.0.2 and *still* see incomplete data, run Remote Diagnostics from **Manage Servers → 🩺** and the sheet will tell you why.
### Migrating from 2.0.0 / 2.0.1 / draft 2.0.1
Sparkle will offer the update automatically. Settings and server list are preserved verbatim. The first time v2.0.2 connects to a remote, it'll create `/tmp/scarf-ssh-<uid>/` with mode 0700; the old `~/Library/Caches/scarf/ssh/` directory becomes unused (you can delete it manually, or leave it — macOS will sweep it eventually).
The previous v2.0.1 draft download remains available for anyone who already grabbed it — it's still a valid build with the diagnostics work. v2.0.2 is the recommended upgrade path.
### Reporters of #19
@cmalpass, @flyespresso, @maikokan — please grab v2.0.2 and confirm the dashboard populates without needing to run Remote Diagnostics first. If it still doesn't, the diagnostics sheet should now have a much better chance of pinpointing what's left.
+17 -1
View File
@@ -141,13 +141,29 @@ Create `.scarf/dashboard.json` in your project root:
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle) - `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 ## Agent Instructions
To have your Hermes agent generate a dashboard, include these 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, > Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
> status indicators, and visualizations. Use the Scarf dashboard schema with sections > status indicators, and visualizations. Use the Scarf dashboard schema with sections
> containing stat, progress, text, table, chart, and list widgets. Register the project > containing stat, progress, text, table, chart, list, and webview widgets. Register the project
> in `~/.hermes/scarf/projects.json` if not already registered. > 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. The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
+50 -24
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; }; 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -33,9 +34,22 @@
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5349593F2F7B83B600BD31AD /* scarf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
534959422F7B83B600BD31AD /* scarf */ = { 534959422F7B83B600BD31AD /* scarf */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
);
path = scarf; path = scarf;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -57,6 +71,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */, 53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
53SPARKLE00010 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -118,6 +133,7 @@
name = scarf; name = scarf;
packageProductDependencies = ( packageProductDependencies = (
53SWIFTTERM0001 /* SwiftTerm */, 53SWIFTTERM0001 /* SwiftTerm */,
53SPARKLE00011 /* Sparkle */,
); );
productName = scarf; productName = scarf;
productReference = 534959402F7B83B600BD31AD /* scarf.app */; productReference = 534959402F7B83B600BD31AD /* scarf.app */;
@@ -203,6 +219,7 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 534959412F7B83B600BD31AD /* Products */; productRefGroup = 534959412F7B83B600BD31AD /* Products */;
@@ -407,22 +424,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_FILE = scarf/Info.plist;
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 = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MACOSX_DEPLOYMENT_TARGET = 14.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -443,22 +458,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_FILE = scarf/Info.plist;
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 = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MACOSX_DEPLOYMENT_TARGET = 14.6;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -475,11 +488,11 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -496,11 +509,11 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -516,10 +529,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -535,10 +548,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 2.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -592,6 +605,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.0;
};
};
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
@@ -603,6 +624,11 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
53SPARKLE00011 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
53SWIFTTERM0001 /* SwiftTerm */ = { 53SWIFTTERM0001 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */; package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
+60 -28
View File
@@ -2,46 +2,78 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(\.serverContext) private var serverContext
/// Per-window connection status. Constructed from the window's
/// `serverContext` once; lifetime matches the window.
@State private var connectionStatus: ConnectionStatusViewModel
init() {
_connectionStatus = State(initialValue: ConnectionStatusViewModel(context: .local))
}
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
SidebarView() SidebarView()
} detail: { } detail: {
detailView detailView
.toolbar {
ToolbarItem(placement: .navigation) {
ServerSwitcherToolbar()
}
if serverContext.isRemote {
// `.principal` centers the pill in the toolbar
// the native emphasis bezel is the intended frame;
// the pill's own visual content (icon + label, no
// background) sits inside it in balance.
ToolbarItem(placement: .principal) {
ConnectionStatusPill(status: connectionStatus)
}
}
}
.onAppear {
// The actual context is injected via @Environment, which
// isn't available in `init`. Rebuild the monitor here
// the first time we know the real context. Safe to call
// repeatedly; `startMonitoring()` cancels + restarts.
if connectionStatus.context.id != serverContext.id {
connectionStatus = ConnectionStatusViewModel(context: serverContext)
}
connectionStatus.startMonitoring()
}
.onDisappear { connectionStatus.stopMonitoring() }
} }
} }
@ViewBuilder @ViewBuilder
private var detailView: some View { private var detailView: some View {
// Each routed view receives the window's `serverContext` in its
// init so its `@State` ViewModel is constructed bound to the right
// server. This is what makes multi-window work without it,
// every window's VMs default-construct with `.local` even though
// the surrounding env has the right context.
switch coordinator.selectedSection { switch coordinator.selectedSection {
case .dashboard: case .dashboard: DashboardView(context: serverContext)
DashboardView() case .insights: InsightsView(context: serverContext)
case .insights: case .sessions: SessionsView(context: serverContext)
InsightsView() case .activity: ActivityView(context: serverContext)
case .sessions: case .projects: ProjectsView(context: serverContext)
SessionsView() case .chat: ChatView()
case .activity: case .memory: MemoryView(context: serverContext)
ActivityView() case .skills: SkillsView(context: serverContext)
case .projects: case .platforms: PlatformsView(context: serverContext)
ProjectsView() case .personalities: PersonalitiesView(context: serverContext)
case .chat: case .quickCommands: QuickCommandsView(context: serverContext)
ChatView() case .credentialPools: CredentialPoolsView(context: serverContext)
case .memory: case .plugins: PluginsView(context: serverContext)
MemoryView() case .webhooks: WebhooksView(context: serverContext)
case .skills: case .profiles: ProfilesView(context: serverContext)
SkillsView() case .tools: ToolsView(context: serverContext)
case .tools: case .mcpServers: MCPServersView(context: serverContext)
ToolsView() case .gateway: GatewayView(context: serverContext)
case .gateway: case .cron: CronView(context: serverContext)
GatewayView() case .health: HealthView(context: serverContext)
case .cron: case .logs: LogsView(context: serverContext)
CronView() case .settings: SettingsView(context: serverContext)
case .health:
HealthView()
case .logs:
LogsView()
case .settings:
SettingsView()
} }
} }
} }
+290
View File
@@ -0,0 +1,290 @@
import Foundation
// MARK: - JSON-RPC Transport
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
// conformance which would prevent these payloads from being encoded or
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
// The member list must stay in sync with the stored properties above.
struct ACPRequest: Encodable, Sendable {
nonisolated let jsonrpc = "2.0"
nonisolated let id: Int
nonisolated let method: String
nonisolated let params: [String: AnyCodable]
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jsonrpc, forKey: .jsonrpc)
try c.encode(id, forKey: .id)
try c.encode(method, forKey: .method)
try c.encode(params, forKey: .params)
}
}
struct ACPRawMessage: Decodable, Sendable {
nonisolated let jsonrpc: String?
nonisolated let id: Int?
nonisolated let method: String?
nonisolated let result: AnyCodable?
nonisolated let error: ACPError?
nonisolated let params: AnyCodable?
nonisolated var isResponse: Bool { id != nil && method == nil }
nonisolated var isNotification: Bool { method != nil && id == nil }
nonisolated var isRequest: Bool { method != nil && id != nil }
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
self.method = try c.decodeIfPresent(String.self, forKey: .method)
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
}
}
struct ACPError: Decodable, Sendable {
nonisolated let code: Int
nonisolated let message: String
enum CodingKeys: String, CodingKey { case code, message }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.code = try c.decode(Int.self, forKey: .code)
self.message = try c.decode(String.self, forKey: .message)
}
}
// MARK: - AnyCodable (for dynamic JSON)
struct AnyCodable: Codable, @unchecked Sendable {
nonisolated let value: Any
nonisolated init(_ value: Any) { self.value = value }
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
// `let value: Any` stored property as MainActor-isolated even when the
// property is declared nonisolated (Any can't be strictly Sendable, so
// the compiler can't prove the write is safe off-main). Leaving the
// init as default-isolated silences the mutation warnings; the Decodable
// conformance is still usable from ACPClient's nonisolated read loop
// because all callers are already @preconcurrency with respect to
// `AnyCodable` (it's @unchecked Sendable).
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map(\.value)
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues(\.value)
} else {
value = NSNull()
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
try container.encodeNil()
}
}
// MARK: - Accessors
nonisolated var stringValue: String? { value as? String }
nonisolated var intValue: Int? { value as? Int }
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
nonisolated var arrayValue: [Any]? { value as? [Any] }
}
// MARK: - ACP Events (parsed from session/update notifications)
enum ACPEvent: Sendable {
case messageChunk(sessionId: String, text: String)
case thoughtChunk(sessionId: String, text: String)
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
case promptComplete(sessionId: String, response: ACPPromptResult)
case availableCommands(sessionId: String, commands: [[String: Any]])
case connectionLost(reason: String)
case unknown(sessionId: String, type: String)
}
struct ACPToolCallEvent: Sendable {
let toolCallId: String
let title: String
let kind: String
let status: String
let content: String
let rawInput: [String: Any]?
var functionName: String {
// title format is "functionName: summary" or just "functionName"
let parts = title.split(separator: ":", maxSplits: 1)
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
}
var argumentsSummary: String {
let parts = title.split(separator: ":", maxSplits: 1)
if parts.count > 1 {
return String(parts[1]).trimmingCharacters(in: .whitespaces)
}
return ""
}
var argumentsJSON: String {
guard let input = rawInput,
let data = try? JSONSerialization.data(withJSONObject: input),
let str = String(data: data, encoding: .utf8) else { return "{}" }
return str
}
}
struct ACPToolCallUpdateEvent: Sendable {
let toolCallId: String
let kind: String
let status: String
let content: String
let rawOutput: String?
}
struct ACPPermissionRequestEvent: Sendable {
let toolCallTitle: String
let toolCallKind: String
let options: [(optionId: String, name: String)]
}
struct ACPPromptResult: Sendable {
let stopReason: String
let inputTokens: Int
let outputTokens: Int
let thoughtTokens: Int
let cachedReadTokens: Int
}
// MARK: - Event Parsing
enum ACPEventParser {
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
guard notification.method == "session/update",
let params = notification.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let update = params["update"] as? [String: Any],
let updateType = update["sessionUpdate"] as? String else {
return nil
}
switch updateType {
case "agent_message_chunk":
let text = extractContentText(from: update)
return .messageChunk(sessionId: sessionId, text: text)
case "agent_thought_chunk":
let text = extractContentText(from: update)
return .thoughtChunk(sessionId: sessionId, text: text)
case "tool_call":
let event = ACPToolCallEvent(
toolCallId: update["toolCallId"] as? String ?? "",
title: update["title"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "pending",
content: extractContentArrayText(from: update),
rawInput: update["rawInput"] as? [String: Any]
)
return .toolCallStart(sessionId: sessionId, call: event)
case "tool_call_update":
let event = ACPToolCallUpdateEvent(
toolCallId: update["toolCallId"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "completed",
content: extractContentArrayText(from: update),
rawOutput: update["rawOutput"] as? String
)
return .toolCallUpdate(sessionId: sessionId, update: event)
case "available_commands_update":
let commands = update["availableCommands"] as? [[String: Any]] ?? []
return .availableCommands(sessionId: sessionId, commands: commands)
default:
return .unknown(sessionId: sessionId, type: updateType)
}
}
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
guard message.method == "session/request_permission",
let params = message.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let requestId = message.id else { return nil }
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
let optionsRaw = params["options"] as? [[String: Any]] ?? []
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
guard let id = opt["optionId"] as? String,
let name = opt["name"] as? String else { return nil }
return (optionId: id, name: name)
}
let event = ACPPermissionRequestEvent(
toolCallTitle: toolCall["title"] as? String ?? "",
toolCallKind: toolCall["kind"] as? String ?? "other",
options: options
)
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
}
// MARK: - Content Extraction
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
if let content = update["content"] as? [String: Any],
let text = content["text"] as? String {
return text
}
return ""
}
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
if let contentArray = update["content"] as? [[String: Any]] {
return contentArray.compactMap { item -> String? in
guard let inner = item["content"] as? [String: Any] else { return nil }
return inner["text"] as? String
}.joined(separator: "\n")
}
return ""
}
}
+433 -12
View File
@@ -1,6 +1,304 @@
import Foundation import Foundation
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
struct AuxiliaryModel: Sendable, Equatable {
var provider: String
var model: String
var baseURL: String
var apiKey: String
var timeout: Int
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
struct DisplaySettings: Sendable, Equatable {
var skin: String
var compact: Bool
var resumeDisplay: String // "full" | "minimal"
var bellOnComplete: Bool
var inlineDiffs: Bool
var toolProgressCommand: Bool
var toolPreviewLength: Int
var busyInputMode: String // e.g. "interrupt"
nonisolated static let empty = DisplaySettings(
skin: "default",
compact: false,
resumeDisplay: "full",
bellOnComplete: false,
inlineDiffs: true,
toolProgressCommand: false,
toolPreviewLength: 0,
busyInputMode: "interrupt"
)
}
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
struct TerminalSettings: Sendable, Equatable {
var cwd: String
var timeout: Int
var envPassthrough: [String]
var persistentShell: Bool
var dockerImage: String
var dockerMountCwdToWorkspace: Bool
var dockerForwardEnv: [String]
var dockerVolumes: [String]
var containerCPU: Int // 0 = unlimited
var containerMemory: Int // MB, 0 = unlimited
var containerDisk: Int // MB, 0 = unlimited
var containerPersistent: Bool
var modalImage: String
var modalMode: String // "auto" | other
var daytonaImage: String
var singularityImage: String
nonisolated static let empty = TerminalSettings(
cwd: ".",
timeout: 180,
envPassthrough: [],
persistentShell: true,
dockerImage: "",
dockerMountCwdToWorkspace: false,
dockerForwardEnv: [],
dockerVolumes: [],
containerCPU: 0,
containerMemory: 0,
containerDisk: 0,
containerPersistent: false,
modalImage: "",
modalMode: "auto",
daytonaImage: "",
singularityImage: ""
)
}
/// Browser automation tuning (`browser.*`).
struct BrowserSettings: Sendable, Equatable {
var inactivityTimeout: Int
var commandTimeout: Int
var recordSessions: Bool
var allowPrivateURLs: Bool
var camofoxManagedPersistence: Bool
nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
struct VoiceSettings: Sendable, Equatable {
var recordKey: String
var maxRecordingSeconds: Int
var silenceDuration: Double
// TTS
var ttsProvider: String
var ttsEdgeVoice: String
var ttsElevenLabsVoiceID: String
var ttsElevenLabsModelID: String
var ttsOpenAIModel: String
var ttsOpenAIVoice: String
var ttsNeuTTSModel: String
var ttsNeuTTSDevice: String
// STT
var sttEnabled: Bool
var sttProvider: String
var sttLocalModel: String
var sttLocalLanguage: String
var sttOpenAIModel: String
var sttMistralModel: String
nonisolated static let empty = VoiceSettings(
recordKey: "ctrl+b",
maxRecordingSeconds: 120,
silenceDuration: 3.0,
ttsProvider: "edge",
ttsEdgeVoice: "en-US-AriaNeural",
ttsElevenLabsVoiceID: "",
ttsElevenLabsModelID: "eleven_multilingual_v2",
ttsOpenAIModel: "gpt-4o-mini-tts",
ttsOpenAIVoice: "alloy",
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
ttsNeuTTSDevice: "cpu",
sttEnabled: true,
sttProvider: "local",
sttLocalModel: "base",
sttLocalLanguage: "",
sttOpenAIModel: "whisper-1",
sttMistralModel: "voxtral-mini-latest"
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
struct AuxiliarySettings: Sendable, Equatable {
var vision: AuxiliaryModel
var webExtract: AuxiliaryModel
var compression: AuxiliaryModel
var sessionSearch: AuxiliaryModel
var skillsHub: AuxiliaryModel
var approval: AuxiliaryModel
var mcp: AuxiliaryModel
var flushMemories: AuxiliaryModel
nonisolated static let empty = AuxiliarySettings(
vision: .empty,
webExtract: .empty,
compression: .empty,
sessionSearch: .empty,
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
)
}
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
struct SecuritySettings: Sendable, Equatable {
var redactSecrets: Bool
var redactPII: Bool // from privacy.redact_pii
var tirithEnabled: Bool
var tirithPath: String
var tirithTimeout: Int
var tirithFailOpen: Bool
var blocklistEnabled: Bool
var blocklistDomains: [String]
nonisolated static let empty = SecuritySettings(
redactSecrets: true,
redactPII: false,
tirithEnabled: true,
tirithPath: "tirith",
tirithTimeout: 5,
tirithFailOpen: true,
blocklistEnabled: false,
blocklistDomains: []
)
}
/// Human-delay simulates realistic typing pace (`human_delay.*`).
struct HumanDelaySettings: Sendable, Equatable {
var mode: String // "off" | "natural" | "custom"
var minMS: Int
var maxMS: Int
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
struct CompressionSettings: Sendable, Equatable {
var enabled: Bool
var threshold: Double
var targetRatio: Double
var protectLastN: Int
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
struct CheckpointSettings: Sendable, Equatable {
var enabled: Bool
var maxSnapshots: Int
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
struct LoggingSettings: Sendable, Equatable {
var level: String // DEBUG | INFO | WARNING | ERROR
var maxSizeMB: Int
var backupCount: Int
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
struct DelegationSettings: Sendable, Equatable {
var model: String
var provider: String
var baseURL: String
var apiKey: String
var maxIterations: Int
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
struct DiscordSettings: Sendable, Equatable {
var requireMention: Bool
var freeResponseChannels: String
var autoThread: Bool
var reactions: Bool
nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
}
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
/// done via environment variables (`TELEGRAM_*`) this is the subset that lives
/// in the YAML.
struct TelegramSettings: Sendable, Equatable {
var requireMention: Bool
var reactions: Bool
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
struct SlackSettings: Sendable, Equatable {
var replyToMode: String // "off" | "first" | "all"
var requireMention: Bool
var replyInThread: Bool
var replyBroadcast: Bool
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
struct MatrixSettings: Sendable, Equatable {
var requireMention: Bool
var autoThread: Bool
var dmMentionThreads: Bool
nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
}
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
/// currently just exposes `group_sessions_per_user` at the top level, but we
/// reserve this struct for future expansion so the form has a stable type.
struct MattermostSettings: Sendable, Equatable {
var requireMention: Bool
var replyMode: String // "thread" | "off"
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
struct WhatsAppSettings: Sendable, Equatable {
var unauthorizedDMBehavior: String // "pair" | "ignore"
var replyPrefix: String
nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
}
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
/// every state change by default; users must opt-in via at least one filter.
struct HomeAssistantSettings: Sendable, Equatable {
var watchDomains: [String]
var watchEntities: [String]
var watchAll: Bool
var ignoreEntities: [String]
var cooldownSeconds: Int
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
struct HermesConfig: Sendable { struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
var model: String var model: String
var provider: String var provider: String
var maxTurns: Int var maxTurns: Int
@@ -15,8 +313,53 @@ struct HermesConfig: Sendable {
var verbose: Bool var verbose: Bool
var autoTTS: Bool var autoTTS: Bool
var silenceThreshold: Int 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( // Phase 1 additions
var timezone: String
var userProfileEnabled: Bool
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
var gatewayTimeout: Int
var approvalTimeout: Int
var fileReadMaxChars: Int
var cronWrapResponse: Bool
var prefillMessagesFile: String
var skillsExternalDirs: [String]
// Grouped blocks
var display: DisplaySettings
var terminal: TerminalSettings
var browser: BrowserSettings
var voice: VoiceSettings
var auxiliary: AuxiliarySettings
var security: SecuritySettings
var humanDelay: HumanDelaySettings
var compression: CompressionSettings
var checkpoints: CheckpointSettings
var logging: LoggingSettings
var delegation: DelegationSettings
var discord: DiscordSettings
var telegram: TelegramSettings
var slack: SlackSettings
var matrix: MatrixSettings
var mattermost: MattermostSettings
var whatsapp: WhatsAppSettings
var homeAssistant: HomeAssistantSettings
nonisolated static let empty = HermesConfig(
model: "unknown", model: "unknown",
provider: "unknown", provider: "unknown",
maxTurns: 0, maxTurns: 0,
@@ -30,17 +373,61 @@ struct HermesConfig: Sendable {
showReasoning: false, showReasoning: false,
verbose: false, verbose: false,
autoTTS: true, autoTTS: true,
silenceThreshold: 200 silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false,
timezone: "",
userProfileEnabled: true,
toolUseEnforcement: "auto",
gatewayTimeout: 1800,
approvalTimeout: 60,
fileReadMaxChars: 100_000,
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
display: .empty,
terminal: .empty,
browser: .empty,
voice: .empty,
auxiliary: .empty,
security: .empty,
humanDelay: .empty,
compression: .empty,
checkpoints: .empty,
logging: .empty,
delegation: .empty,
discord: .empty,
telegram: .empty,
slack: .empty,
matrix: .empty,
mattermost: .empty,
whatsapp: .empty,
homeAssistant: .empty
) )
} }
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Decodable conformance (which would fail to be used from
// `HermesFileService.loadGatewayState()`, a nonisolated method).
struct GatewayState: Sendable, Codable { struct GatewayState: Sendable, Codable {
let pid: Int? nonisolated let pid: Int?
let kind: String? nonisolated let kind: String?
let gatewayState: String? nonisolated let gatewayState: String?
let exitReason: String? nonisolated let exitReason: String?
let platforms: [String: PlatformState]? nonisolated let platforms: [String: PlatformState]?
let updatedAt: String? nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case pid, kind case pid, kind
@@ -50,16 +437,50 @@ struct GatewayState: Sendable, Codable {
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
var isRunning: Bool { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(pid, forKey: .pid)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
try c.encodeIfPresent(exitReason, forKey: .exitReason)
try c.encodeIfPresent(platforms, forKey: .platforms)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
nonisolated var isRunning: Bool {
gatewayState == "running" gatewayState == "running"
} }
var statusText: String { nonisolated var statusText: String {
gatewayState ?? "unknown" gatewayState ?? "unknown"
} }
} }
struct PlatformState: Sendable, Codable { struct PlatformState: Sendable, Codable {
let connected: Bool? nonisolated let connected: Bool?
let error: String? nonisolated let error: String?
enum CodingKeys: String, CodingKey { case connected, error }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(connected, forKey: .connected)
try c.encodeIfPresent(error, forKey: .error)
}
} }
+89 -17
View File
@@ -1,21 +1,93 @@
import Foundation import Foundation
import SQLite3
/// Deprecated module-level path statics. Preserved as thin forwarders to
/// `ServerContext.local.paths` so existing call sites continue to compile
/// while Phase 1 migrates them to a per-server `ServerContext`.
///
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
enum HermesPaths: Sendable { enum HermesPaths: Sendable {
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory @available(*, deprecated, message: "use ServerContext.paths.home")
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes" nonisolated static var home: String { ServerContext.local.paths.home }
nonisolated static let stateDB: String = home + "/state.db"
nonisolated static let configYAML: String = home + "/config.yaml" @available(*, deprecated, message: "use ServerContext.paths.stateDB")
nonisolated static let memoriesDir: String = home + "/memories" nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md"
nonisolated static let userMD: String = memoriesDir + "/USER.md" @available(*, deprecated, message: "use ServerContext.paths.configYAML")
nonisolated static let sessionsDir: String = home + "/sessions" nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json"
nonisolated static let cronOutputDir: String = home + "/cron/output" @available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log" @available(*, deprecated, message: "use ServerContext.paths.memoryMD")
nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf" @available(*, deprecated, message: "use ServerContext.paths.userMD")
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json" nonisolated static var userMD: String { ServerContext.local.paths.userMD }
@available(*, deprecated, message: "use ServerContext.paths.sessionsDir")
nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir }
@available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON")
nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON }
@available(*, deprecated, message: "use ServerContext.paths.cronOutputDir")
nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir }
@available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON")
nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON }
@available(*, deprecated, message: "use ServerContext.paths.skillsDir")
nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir }
@available(*, deprecated, message: "use ServerContext.paths.errorsLog")
nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog }
@available(*, deprecated, message: "use ServerContext.paths.agentLog")
nonisolated static var agentLog: String { ServerContext.local.paths.agentLog }
@available(*, deprecated, message: "use ServerContext.paths.gatewayLog")
nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog }
@available(*, deprecated, message: "use ServerContext.paths.scarfDir")
nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir }
@available(*, deprecated, message: "use ServerContext.paths.projectsRegistry")
nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry }
@available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir")
nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir }
@available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates")
nonisolated static var hermesBinaryCandidates: [String] {
HermesPathSet.hermesBinaryCandidates
}
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
}
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
} }
+121 -20
View File
@@ -1,27 +1,82 @@
import Foundation import Foundation
struct HermesCronJob: Identifiable, Sendable, Codable { struct HermesCronJob: Identifiable, Sendable, Codable {
let id: String nonisolated let id: String
let name: String nonisolated let name: String
let prompt: String nonisolated let prompt: String
let skills: [String]? nonisolated let skills: [String]?
let model: String? nonisolated let model: String?
let schedule: CronSchedule nonisolated let schedule: CronSchedule
let enabled: Bool nonisolated let enabled: Bool
let state: String nonisolated let state: String
let deliver: String? nonisolated let deliver: String?
let nextRunAt: String? nonisolated let nextRunAt: String?
let lastRunAt: String? nonisolated let lastRunAt: String?
let lastError: String? nonisolated let lastError: String?
nonisolated let preRunScript: String?
nonisolated let deliveryFailures: Int?
nonisolated let lastDeliveryError: String?
nonisolated let timeoutType: String?
nonisolated let timeoutSeconds: Int?
nonisolated let silent: Bool?
enum CodingKeys: String, CodingKey { 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 nextRunAt = "next_run_at"
case lastRunAt = "last_run_at" case lastRunAt = "last_run_at"
case lastError = "last_error" 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 { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id)
self.name = try c.decode(String.self, forKey: .name)
self.prompt = try c.decode(String.self, forKey: .prompt)
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
self.model = try c.decodeIfPresent(String.self, forKey: .model)
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
self.enabled = try c.decode(Bool.self, forKey: .enabled)
self.state = try c.decode(String.self, forKey: .state)
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(prompt, forKey: .prompt)
try c.encodeIfPresent(skills, forKey: .skills)
try c.encodeIfPresent(model, forKey: .model)
try c.encode(schedule, forKey: .schedule)
try c.encode(enabled, forKey: .enabled)
try c.encode(state, forKey: .state)
try c.encodeIfPresent(deliver, forKey: .deliver)
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
try c.encodeIfPresent(lastError, forKey: .lastError)
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try c.encodeIfPresent(silent, forKey: .silent)
}
nonisolated var stateIcon: String {
switch state { switch state {
case "scheduled": return "clock" case "scheduled": return "clock"
case "running": return "play.circle" case "running": return "play.circle"
@@ -30,13 +85,28 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
default: return "questionmark.circle" default: return "questionmark.circle"
} }
} }
nonisolated var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") {
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2 {
return "Discord thread \(parts[1]) in \(parts[0])"
}
if parts.count == 1 {
return "Discord \(parts[0])"
}
}
return deliver
}
} }
struct CronSchedule: Sendable, Codable { struct CronSchedule: Sendable, Codable {
let kind: String nonisolated let kind: String
let runAt: String? nonisolated let runAt: String?
let display: String? nonisolated let display: String?
let expression: String? nonisolated let expression: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case kind case kind
@@ -44,14 +114,45 @@ struct CronSchedule: Sendable, Codable {
case display case display
case expression case expression
} }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind)
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
self.display = try c.decodeIfPresent(String.self, forKey: .display)
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
} }
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(kind, forKey: .kind)
try c.encodeIfPresent(runAt, forKey: .runAt)
try c.encodeIfPresent(display, forKey: .display)
try c.encodeIfPresent(expression, forKey: .expression)
}
}
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Codable conformance `HermesFileService.loadCronJobs`
// is nonisolated and needs to decode this from a background task.
struct CronJobsFile: Sendable, Codable { struct CronJobsFile: Sendable, Codable {
let jobs: [HermesCronJob] nonisolated let jobs: [HermesCronJob]
let updatedAt: String? nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case jobs case jobs
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jobs, forKey: .jobs)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
} }
@@ -0,0 +1,54 @@
import Foundation
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
var id: String { rawValue }
var displayName: String {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
struct HermesMCPServer: Identifiable, Sendable, Equatable {
let name: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let env: [String: String]
let headers: [String: String]
let timeout: Int?
let connectTimeout: Int?
let enabled: Bool
let toolsInclude: [String]
let toolsExclude: [String]
let resourcesEnabled: Bool
let promptsEnabled: Bool
let hasOAuthToken: Bool
var id: String { name }
var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
struct MCPTestResult: Sendable, Equatable {
let serverName: String
let succeeded: Bool
let output: String
let tools: [String]
let elapsed: TimeInterval
}
+3 -1
View File
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
let timestamp: Date? let timestamp: Date?
let tokenCount: Int? let tokenCount: Int?
let finishReason: String? let finishReason: String?
let reasoning: String?
var isUser: Bool { role == "user" } var isUser: Bool { role == "user" }
var isAssistant: Bool { role == "assistant" } var isAssistant: Bool { role == "assistant" }
var isToolResult: Bool { role == "tool" } var isToolResult: Bool { role == "tool" }
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
} }
struct HermesToolCall: Identifiable, Sendable, Codable { struct HermesToolCall: Identifiable, Sendable, Codable {
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
switch functionName { switch functionName {
case "read_file", "search_files", "vision_analyze": return .read case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit case "write_file", "patch": return .edit
case "terminal": return .execute case "terminal", "execute_code": return .execute
case "web_search", "web_extract": return .fetch case "web_search", "web_extract": return .fetch
case "browser_navigate", "browser_click", "browser_screenshot": return .browser case "browser_navigate", "browser_click", "browser_screenshot": return .browser
default: return .other default: return .other
@@ -0,0 +1,92 @@
import Foundation
/// The filesystem layout of a Hermes installation, parameterized by the
/// `home` directory. The same layout is used for local installations (where
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
/// remote installations reached over SSH (where `home` is a remote path like
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
/// will resolve).
///
/// Every path that used to live as a module-level static on `HermesPaths` is
/// an instance property here. `ServerContext.paths` is the canonical way to
/// reach these values; the old `HermesPaths` statics are preserved as
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
struct HermesPathSet: Sendable, Hashable {
let home: String
/// `true` when this path set belongs to a remote installation. Affects
/// only `hermesBinary` resolution every other path is identical in
/// shape between local and remote.
let isRemote: Bool
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
/// Populated by `SSHTransport` once `command -v hermes` has run on the
/// target host. Unused when `isRemote == false`.
let binaryHint: String?
// MARK: - Defaults
/// Absolute path to the local user's `~/.hermes` directory.
nonisolated static let defaultLocalHome: String = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}()
/// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it.
nonisolated static let defaultRemoteHome: String = "~/.hermes"
// MARK: - Paths (mirror of the old HermesPaths layout)
nonisolated var stateDB: String { home + "/state.db" }
nonisolated var configYAML: String { home + "/config.yaml" }
nonisolated var envFile: String { home + "/.env" }
nonisolated var authJSON: String { home + "/auth.json" }
nonisolated var soulMD: String { home + "/SOUL.md" }
nonisolated var pluginsDir: String { home + "/plugins" }
nonisolated var memoriesDir: String { home + "/memories" }
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
nonisolated var userMD: String { memoriesDir + "/USER.md" }
nonisolated var sessionsDir: String { home + "/sessions" }
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
nonisolated var cronOutputDir: String { home + "/cron/output" }
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
nonisolated var skillsDir: String { home + "/skills" }
nonisolated var errorsLog: String { home + "/logs/errors.log" }
nonisolated var agentLog: String { home + "/logs/agent.log" }
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
nonisolated var scarfDir: String { home + "/scarf" }
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
/// Install locations we probe for the local `hermes` binary, in priority
/// order. Checked on every access so a user installing via a different
/// method doesn't need to relaunch Scarf.
nonisolated static let hermesBinaryCandidates: [String] = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return [
user + "/.local/bin/hermes", // pipx / pip --user (default)
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
user + "/.hermes/bin/hermes" // Some self-install layouts
]
}()
/// Resolved path to the `hermes` executable for this installation.
///
/// Local: returns the first executable candidate, falling back to the
/// pipx default so error messages still make sense on a fresh machine.
///
/// Remote: returns `binaryHint` (populated at connect time) or bare
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
nonisolated var hermesBinary: String {
if isRemote {
return binaryHint ?? "hermes"
}
for path in Self.hermesBinaryCandidates
where FileManager.default.isExecutableFile(atPath: path) {
return path
}
return Self.hermesBinaryCandidates[0]
}
}
+26 -9
View File
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
let cacheReadTokens: Int let cacheReadTokens: Int
let cacheWriteTokens: Int let cacheWriteTokens: Int
let estimatedCostUSD: Double? 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? { var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil } guard let start = startedAt, let end = endedAt else { return nil }
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
} }
var sourceIcon: String { var sourceIcon: String {
switch source { KnownPlatforms.icon(for: source)
case "cli": return "terminal" }
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right" func withTitle(_ newTitle: String) -> HermesSession {
case "slack": return "number" HermesSession(
case "email": return "envelope" id: id, source: source, userId: userId, model: model,
default: return "bubble.left" 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 category: String
let path: String let path: String
let files: [String] let files: [String]
let requiredConfig: [String]
} }
+27 -1
View File
@@ -16,13 +16,39 @@ struct HermesToolPlatform: Identifiable, Sendable {
} }
enum KnownPlatforms { enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [ static let all: [HermesToolPlatform] = [
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"), cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"), HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"), HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"), HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"), HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
] ]
static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
} }
@@ -0,0 +1,174 @@
import Foundation
struct MCPServerPreset: Identifiable, Sendable, Equatable {
let id: String
let displayName: String
let description: String
let category: String
let iconSystemName: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let requiredEnvKeys: [String]
let optionalEnvKeys: [String]
let pathArgPrompt: String?
let docsURL: String
static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
description: "Read and write files under a root directory you choose.",
category: "Built-in",
iconSystemName: "folder",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Root directory (absolute path)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
),
MCPServerPreset(
id: "github",
displayName: "GitHub",
description: "Issues, pull requests, code search, and file operations via GitHub API.",
category: "Dev",
iconSystemName: "chevron.left.forwardslash.chevron.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil,
auth: nil,
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
),
MCPServerPreset(
id: "postgres",
displayName: "Postgres",
description: "Read-only SQL access against a Postgres database.",
category: "Data",
iconSystemName: "cylinder.split.1x2",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
),
MCPServerPreset(
id: "slack",
displayName: "Slack",
description: "Read channels, post messages, and search your Slack workspace.",
category: "Productivity",
iconSystemName: "bubble.left.and.bubble.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-slack"],
url: nil,
auth: nil,
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
),
MCPServerPreset(
id: "linear",
displayName: "Linear",
description: "Query and update Linear issues. Uses OAuth — no token needed.",
category: "Productivity",
iconSystemName: "list.bullet.rectangle",
transport: .http,
command: nil,
args: [],
url: "https://mcp.linear.app/sse",
auth: "oauth",
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://linear.app/docs/mcp"
),
MCPServerPreset(
id: "sentry",
displayName: "Sentry",
description: "Investigate errors and performance issues from Sentry.",
category: "Dev",
iconSystemName: "exclamationmark.triangle",
transport: .stdio,
command: "npx",
args: ["-y", "@sentry/mcp-server"],
url: nil,
auth: nil,
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://docs.sentry.io/product/mcp/"
),
MCPServerPreset(
id: "puppeteer",
displayName: "Puppeteer",
description: "Headless browser automation — navigate pages, click, screenshot.",
category: "Automation",
iconSystemName: "safari",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
),
MCPServerPreset(
id: "memory",
displayName: "Memory (Knowledge Graph)",
description: "Persistent knowledge graph of entities and relations across sessions.",
category: "Built-in",
iconSystemName: "brain",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: ["MEMORY_FILE_PATH"],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
),
MCPServerPreset(
id: "fetch",
displayName: "Fetch",
description: "Retrieve and convert web pages to markdown.",
category: "Built-in",
iconSystemName: "arrow.down.circle",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
)
]
static var categories: [String] {
var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
}
static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
@@ -69,6 +69,10 @@ struct DashboardWidget: Codable, Sendable, Identifiable {
// List // List
let items: [ListItem]? let items: [ListItem]?
// Webview
let url: String?
let height: Double?
} }
// MARK: - Widget Value (String or Number) // MARK: - Widget Value (String or Number)
+253
View File
@@ -0,0 +1,253 @@
import Foundation
import SwiftUI
import AppKit
/// Stable identifier for a server entry in the user's registry. Backed by
/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
/// restoration without collisions.
typealias ServerID = UUID
/// Connection parameters for a remote Hermes installation reached over SSH.
/// All fields are optional except `host` unset values defer to the user's
/// `~/.ssh/config` and the OpenSSH defaults.
struct SSHConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config` alias.
var host: String
/// Remote username. `nil` defer to `~/.ssh/config` or the local user.
var user: String?
/// TCP port. `nil` 22 (or whatever `~/.ssh/config` says).
var port: Int?
/// Absolute path to a private key. `nil` defer to ssh-agent /
/// `~/.ssh/config` identity files.
var identityFile: String?
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
var remoteHome: String?
/// Resolved remote path to the `hermes` binary. Populated by
/// `SSHTransport` after the first `command -v hermes` probe; cached here
/// so subsequent calls skip the round trip.
var hermesBinaryHint: String?
}
/// Distinguishes a local installation (the user's own `~/.hermes`) from a
/// remote one reached over SSH. Service behavior is identical in shape but
/// dispatches to different I/O primitives in Phase 2.
enum ServerKind: Sendable, Hashable, Codable {
case local
case ssh(SSHConfig)
}
/// The per-server value that flows through `.environment` and gets handed to
/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to
/// one Hermes installation; multi-window scenes in Phase 3 will construct
/// one per window.
///
/// **Why every member is `nonisolated`.** This file imports `AppKit`
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
/// upcoming default-isolation rules pulls the whole struct to `@MainActor`.
/// `ServerContext` is a plain `Sendable` value accessing `.local`, `.paths`,
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
/// the caller into hopping MainActor. `nonisolated` on each member keeps
/// them callable from any context; the one MainActor-dependent method
/// (`openInLocalEditor`) lives in the extension below.
struct ServerContext: Sendable, Hashable, Identifiable {
let id: ServerID
var displayName: String
var kind: ServerKind
/// Path layout for this server. Cheap all path components are computed
/// on demand from `home`, no I/O.
nonisolated var paths: HermesPathSet {
switch kind {
case .local:
return HermesPathSet(
home: HermesPathSet.defaultLocalHome,
isRemote: false,
binaryHint: nil
)
case .ssh(let config):
return HermesPathSet(
home: config.remoteHome ?? HermesPathSet.defaultRemoteHome,
isRemote: true,
binaryHint: config.hermesBinaryHint
)
}
}
nonisolated var isRemote: Bool {
if case .ssh = kind { return true }
return false
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig`. Each call returns a fresh value transports are
/// cheap and stateless beyond disk caches.
nonisolated func makeTransport() -> any ServerTransport {
switch kind {
case .local:
return LocalTransport(contextID: id)
case .ssh(let config):
return SSHTransport(contextID: id, config: config, displayName: displayName)
}
}
// MARK: - Well-known singletons
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
/// local context has the same identity across launches, and so persisted
/// window-state restorations that reference it continue to resolve even
/// if `servers.json` hasn't been touched yet.
nonisolated private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")!
/// The default "this machine" context. Used everywhere in Phase 0/1 and
/// remains the fallback when no remote server is selected.
nonisolated static let local = ServerContext(
id: localID,
displayName: "Local",
kind: .local
)
}
// MARK: - Remote user-home resolution
/// Process-wide cache of each server's resolved user `$HOME`. Probed once per
/// `ServerID` via the transport, then memoized for the app's lifetime home
/// directories don't change under us, and the probe is a ~5ms SSH round-trip
/// with ControlMaster. Used by anything that needs to hand a working
/// directory to the ACP agent or the Hermes CLI on the correct host.
private actor UserHomeCache {
static let shared = UserHomeCache()
private var cache: [ServerID: String] = [:]
func resolve(for context: ServerContext) async -> String {
if let cached = cache[context.id] { return cached }
let resolved = await probe(context: context)
cache[context.id] = resolved
return resolved
}
func invalidate(contextID: ServerID) {
cache.removeValue(forKey: contextID)
}
private func probe(context: ServerContext) async -> String {
if !context.isRemote { return NSHomeDirectory() }
let transport = context.makeTransport()
let result = try? transport.runProcess(
executable: "/bin/sh",
args: ["-c", "echo $HOME"],
stdin: nil,
timeout: 10
)
let out = result?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Fall back to `~` (unexpanded) so ACP at least gets a plausible cwd
// rather than a local Mac path. The remote side will expand it if
// passed through a shell; if not, failures are surfaced by ACP itself.
return out.isEmpty ? "~" : out
}
}
extension ServerContext {
/// Resolved absolute path to the user's home directory on the target host.
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
/// Use this not `NSHomeDirectory()` whenever you're passing a `cwd`
/// or user path to a process that runs on the target host.
func resolvedUserHome() async -> String {
await UserHomeCache.shared.resolve(for: self)
}
/// Called when a server is removed from the registry, so the process-wide
/// caches keyed by `ServerID` don't hold stale entries forever.
static func invalidateCaches(for contextID: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: contextID)
}
}
// MARK: - Convenience file I/O via the right transport
/// Centralized file I/O entry points for VMs that don't own a service. Every
/// call goes through the context's transport, so reads/writes hit the local
/// disk for `.local` and ssh/scp for `.ssh` automatically.
///
/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)`
/// when the path comes from `context.paths`. The Foundation file APIs are
/// LOCAL ONLY using them with a remote path silently returns nil because
/// the remote path doesn't exist on this Mac.
extension ServerContext {
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
/// invalid encoding).
nonisolated func readText(_ path: String) -> String? {
guard let data = try? makeTransport().readFile(path) else { return nil }
return String(data: data, encoding: .utf8)
}
/// Read raw bytes. `nil` on any error.
nonisolated func readData(_ path: String) -> Data? {
try? makeTransport().readFile(path)
}
/// Atomic write. Returns `true` on success, `false` on any error
/// (caller is expected to surface failures via UI when relevant).
@discardableResult
nonisolated func writeText(_ path: String, content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false }
do {
try makeTransport().writeFile(path, data: data)
return true
} catch {
return false
}
}
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
nonisolated func fileExists(_ path: String) -> Bool {
makeTransport().fileExists(path)
}
/// File modification timestamp, or `nil` if the file doesn't exist.
nonisolated func modificationDate(_ path: String) -> Date? {
makeTransport().stat(path)?.mtime
}
/// Invoke the `hermes` CLI on this server and return its combined output
/// + exit code. Local: spawns the local binary via `Process`. Remote:
/// rounds through `ssh host hermes `. Use this from any VM that needs
/// to fire off a CLI command never spawn `hermes` via `Process()`
/// directly, because that path bypasses the transport for remote.
@discardableResult
nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) {
let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin)
return (result.output, result.exitCode)
}
/// Reveal the file at `path` in the user's local editor (via
/// `NSWorkspace.open`). For remote contexts this is a no-op the
/// file doesn't exist on this Mac, so opening it would fail silently
/// or worse, open the wrong file from the local filesystem.
/// Returns `true` if opened, `false` if the call was skipped.
@discardableResult
func openInLocalEditor(_ path: String) -> Bool {
guard !isRemote else { return false }
NSWorkspace.shared.open(URL(fileURLWithPath: path))
return true
}
}
// MARK: - SwiftUI environment plumbing
/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which
/// requires an `@Observable` class) doesn't accept it directly. We expose it
/// through a custom `EnvironmentKey` views read it with
/// `@Environment(\.serverContext) private var serverContext`.
private struct ServerContextEnvironmentKey: EnvironmentKey {
static let defaultValue: ServerContext = .local
}
extension EnvironmentValues {
var serverContext: ServerContext {
get { self[ServerContextEnvironmentKey.self] }
set { self[ServerContextEnvironmentKey.self] = newValue }
}
}
@@ -0,0 +1,167 @@
import Foundation
import os
/// Persisted entry for a user-added server. `ServerContext` itself is a value
/// type we rebuild from these fields at runtime we persist the minimum that
/// uniquely identifies a connection, not the whole context struct, so future
/// fields we add to `ServerContext` don't force a migration.
struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
var id: ServerID
var displayName: String
var kind: ServerKind
/// User preference: open this server in a window on launch. Phase 3
/// multi-window uses this; Phase 2 ignores it.
var openOnLaunch: Bool = false
var context: ServerContext {
ServerContext(id: id, displayName: displayName, kind: kind)
}
}
/// On-disk envelope for `servers.json`. Schema-versioned so future changes
/// can migrate without losing data.
private struct RegistryFile: Codable {
var schemaVersion: Int
var entries: [ServerEntry]
}
/// App-scoped store for user-added servers. `local` is synthesized (not
/// persisted) and always appears first in `allContexts`. Remote entries are
/// loaded from `~/Library/Application Support/scarf/servers.json`.
///
/// Observable so SwiftUI views binding to `entries` redraw when a server is
/// added, renamed, or removed.
@Observable
@MainActor
final class ServerRegistry {
private static let logger = Logger(subsystem: "com.scarf", category: "ServerRegistry")
private static let currentSchemaVersion = 1
/// Remote (user-added) entries. Observable: views redraw on mutation.
private(set) var entries: [ServerEntry] = []
private let storeURL: URL
init() {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support")
let dir = support.appendingPathComponent("scarf", isDirectory: true)
self.storeURL = dir.appendingPathComponent("servers.json")
load()
}
// MARK: - Lookup
/// The implicit local server plus every persisted remote entry, in list
/// order. Use this when populating UI like the toolbar switcher.
var allContexts: [ServerContext] {
[.local] + entries.map { $0.context }
}
/// Resolve an ID to a context, or `nil` if the entry no longer exists.
/// Used by the multi-window root to detect "this window points at a
/// server you've since removed" and show a dedicated empty state.
func context(for id: ServerID) -> ServerContext? {
if id == ServerContext.local.id { return .local }
if let entry = entries.first(where: { $0.id == id }) {
return entry.context
}
return nil
}
// MARK: - Mutations
/// Optional callback fired whenever `entries` changes. The app wires
/// this to `ServerLiveStatusRegistry.rebuild()` so the menu-bar fanout
/// stays in sync without polling the entries array.
var onEntriesChanged: (() -> Void)?
@discardableResult
func addServer(displayName: String, config: SSHConfig) -> ServerEntry {
let entry = ServerEntry(
id: ServerID(),
displayName: displayName,
kind: .ssh(config)
)
entries.append(entry)
save()
onEntriesChanged?()
return entry
}
func updateServer(_ id: ServerID, displayName: String?, config: SSHConfig?) {
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return }
if let name = displayName { entries[idx].displayName = name }
if let cfg = config { entries[idx].kind = .ssh(cfg) }
save()
onEntriesChanged?()
}
func removeServer(_ id: ServerID) {
// Grab the entry BEFORE removing it so we can tear down its transport
// state. Without this the user would leak a ControlMaster socket
// (~10min TTL) and a snapshot cache dir (indefinite) per removed
// server harmless individually, ugly at scale.
let removed = entries.first { $0.id == id }
entries.removeAll { $0.id == id }
save()
if let removed, case .ssh(let config) = removed.kind {
let transport = SSHTransport(contextID: id, config: config, displayName: removed.displayName)
transport.closeControlMaster()
}
SSHTransport.pruneSnapshotCache(for: id)
// Drop process-wide cache entries keyed on this ServerID so a future
// re-add with a colliding ID (theoretical UUIDs are random, but be
// defensive) doesn't serve stale data.
Task.detached { await ServerContext.invalidateCaches(for: id) }
onEntriesChanged?()
}
// MARK: - App-launch sweep
/// Remove snapshot cache directories whose UUID isn't in the current
/// registry. Handles the case where the user removed a server while the
/// app was closed we want the cache to converge to the registry's
/// state at launch rather than carrying forever.
func sweepOrphanCaches() {
var keep: Set<ServerID> = [ServerContext.local.id]
for entry in entries { keep.insert(entry.id) }
SSHTransport.sweepOrphanSnapshots(keeping: keep)
SSHTransport.sweepStaleControlSockets()
}
// MARK: - Persistence
private func load() {
guard FileManager.default.fileExists(atPath: storeURL.path) else {
entries = []
return
}
do {
let data = try Data(contentsOf: storeURL)
let file = try JSONDecoder().decode(RegistryFile.self, from: data)
entries = file.entries
} catch {
Self.logger.error("Failed to load servers.json: \(error.localizedDescription)")
entries = []
}
}
private func save() {
do {
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let file = RegistryFile(schemaVersion: Self.currentSchemaVersion, entries: entries)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
try data.write(to: storeURL, options: .atomic)
} catch {
Self.logger.error("Failed to save servers.json: \(error.localizedDescription)")
}
}
}
+604
View File
@@ -0,0 +1,604 @@
import Foundation
import os
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
/// Provides an async event stream for real-time session updates.
actor ACPClient {
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var stdinFd: Int32 = -1
private var nextRequestId = 1
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
private var _eventStream: AsyncStream<ACPEvent>?
private(set) var isConnected = false
private(set) var currentSessionId: String?
private(set) var statusMessage = ""
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
/// Ring buffer of recent stderr lines from `hermes acp` used to attach
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
/// growth when the subprocess logs heavily.
private var stderrBuffer: [String] = []
private static let stderrBufferMaxLines = 50
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
/// `hermes acp` subprocess, joined by newlines.
var recentStderr: String {
stderrBuffer.joined(separator: "\n")
}
fileprivate func appendStderr(_ text: String) {
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
stderrBuffer.append(String(line))
}
if stderrBuffer.count > Self.stderrBufferMaxLines {
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
}
}
/// Check if the underlying process is still alive and connected.
var isHealthy: Bool {
guard isConnected, let process else { return false }
return process.isRunning
}
// MARK: - Event Stream
/// Access the event stream. Must call `start()` first.
var events: AsyncStream<ACPEvent> {
guard let stream = _eventStream else {
// Return an empty stream if not started
return AsyncStream { $0.finish() }
}
return stream
}
// MARK: - Lifecycle
func start() async throws {
guard process == nil else { return }
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing
signal(SIGPIPE, SIG_IGN)
// Create the event stream BEFORE anything else so no events are lost
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
self._eventStream = stream
self.eventContinuation = continuation
// For local: Process is `hermes acp` directly.
// For remote: the transport returns a Process configured as
// `/usr/bin/ssh -T <opts> host -- <hermes> acp`. ACP's JSON-RPC
// over stdio works identically because `-T` keeps the ssh channel
// byte-clean and stdin/stdout travel end-to-end unmodified.
let proc = transport.makeProcess(
executable: context.paths.hermesBinary,
args: ["acp"]
)
let stdin = Pipe()
let stdout = Pipe()
let stderr = Pipe()
proc.standardInput = stdin
proc.standardOutput = stdout
proc.standardError = stderr
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution.
if context.isRemote {
// Remote: this is the LOCAL ssh process spawning `ssh host
// hermes acp`. We don't forward our local PATH/credentials to
// the remote (hermes runs under the remote user's login env),
// but the ssh binary itself needs SSH_AUTH_SOCK to reach the
// local ssh-agent for key-based auth.
var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
env.removeValue(forKey: "TERM")
proc.environment = env
} else {
// Local: enriched env so any tools hermes spawns (MCP servers,
// shell commands) can find brew/nvm/asdf binaries on PATH.
var env = HermesFileService.enrichedEnvironment()
env.removeValue(forKey: "TERM")
proc.environment = env
}
proc.terminationHandler = { [weak self] proc in
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
}
statusMessage = "Starting hermes acp..."
do {
try proc.run()
} catch {
statusMessage = "Failed to start: \(error.localizedDescription)"
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
continuation.finish()
throw error
}
self.process = proc
self.stdinPipe = stdin
self.stdoutPipe = stdout
self.stderrPipe = stderr
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
self.isConnected = true
// Start reading stdout BEFORE sending initialize (so we catch the response)
startReadLoop(stdout: stdout, stderr: stderr)
logger.info("hermes acp process started (pid: \(proc.processIdentifier))")
statusMessage = "Initializing..."
// Initialize the ACP connection
let initParams: [String: AnyCodable] = [
"protocolVersion": AnyCodable(1),
"clientCapabilities": AnyCodable([String: Any]()),
"clientInfo": AnyCodable([
"name": "Scarf",
"version": "1.0"
] as [String: Any])
]
_ = try await sendRequest(method: "initialize", params: initParams)
statusMessage = "Connected"
logger.info("ACP connection initialized")
startKeepalive()
}
func stop() async {
readTask?.cancel()
readTask = nil
stderrTask?.cancel()
stderrTask = nil
keepaliveTask?.cancel()
keepaliveTask = nil
eventContinuation?.finish()
eventContinuation = nil
_eventStream = nil
for (_, continuation) in pendingRequests {
continuation.resume(throwing: CancellationError())
}
pendingRequests.removeAll()
// Close stdin first so the subprocess sees EOF and can shut down gracefully
stdinPipe?.fileHandleForWriting.closeFile()
if let process, process.isRunning {
// SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly)
process.interrupt()
// Watchdog: force-kill if still running after 2 seconds
let watchdogProcess = process
Task.detached {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if watchdogProcess.isRunning {
watchdogProcess.terminate()
}
}
}
stdinPipe?.fileHandleForReading.closeFile()
stdoutPipe?.fileHandleForReading.closeFile()
stderrPipe?.fileHandleForReading.closeFile()
process = nil
stdinPipe = nil
stdoutPipe = nil
stderrPipe = nil
stdinFd = -1
isConnected = false
currentSessionId = nil
statusMessage = "Disconnected"
logger.info("ACP client stopped")
}
// MARK: - Keepalive
private func startKeepalive() {
keepaliveTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
guard !Task.isCancelled else { break }
await self?.sendKeepalive()
}
}
}
/// Valid JSON-RPC notification used as a keepalive probe.
/// Sending bare newlines causes `json.loads("")` errors in the ACP library.
private static let keepalivePayload: Data = {
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n"
return Data(json.utf8)
}()
private func sendKeepalive() {
let fd = stdinFd
guard fd >= 0 else { return }
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload)
if !ok {
await self?.handleWriteFailed()
}
}
}
// MARK: - Session Management
func newSession(cwd: String) async throws -> String {
statusMessage = "Creating session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/new", params: params)
guard let dict = result?.dictValue,
let sessionId = dict["sessionId"] as? String else {
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
}
currentSessionId = sessionId
statusMessage = "Session ready"
logger.info("Created new ACP session: \(sessionId)")
return sessionId
}
func loadSession(cwd: String, sessionId: String) async throws -> String {
statusMessage = "Loading session \(sessionId.prefix(12))..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/load", params: params)
// ACP returns {} on success (no sessionId echoed), or an error if not found.
// If we got here without throwing, the session was loaded. Use the ID we sent.
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
currentSessionId = loadedId
statusMessage = "Session loaded"
logger.info("Loaded ACP session: \(loadedId)")
return loadedId
}
func resumeSession(cwd: String, sessionId: String) async throws -> String {
statusMessage = "Resuming session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/resume", params: params)
guard let dict = result?.dictValue,
let resumedId = dict["sessionId"] as? String else {
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
}
currentSessionId = resumedId
statusMessage = "Session resumed"
logger.info("Resumed ACP session: \(resumedId)")
return resumedId
}
// MARK: - Messaging
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
statusMessage = "Sending prompt..."
let messageId = UUID().uuidString
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId),
"messageId": AnyCodable(messageId),
"prompt": AnyCodable([
["type": "text", "text": text] as [String: Any]
] as [Any])
]
let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:]
let usage = dict["usage"] as? [String: Any] ?? [:]
statusMessage = "Ready"
return ACPPromptResult(
stopReason: dict["stopReason"] as? String ?? "end_turn",
inputTokens: usage["inputTokens"] as? Int ?? 0,
outputTokens: usage["outputTokens"] as? Int ?? 0,
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
)
}
func cancel(sessionId: String) async throws {
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId)
]
_ = try await sendRequest(method: "session/cancel", params: params)
statusMessage = "Cancelled"
}
func respondToPermission(requestId: Int, optionId: String) {
let response: [String: Any] = [
"jsonrpc": "2.0",
"id": requestId,
"result": [
"outcome": [
"kind": optionId == "deny" ? "rejected" : "allowed",
"optionId": optionId
] as [String: Any]
] as [String: Any]
]
writeJSON(response)
}
// MARK: - JSON-RPC Transport
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
let requestId = nextRequestId
nextRequestId += 1
let request = ACPRequest(id: requestId, method: method, params: params)
guard let data = try? JSONEncoder().encode(request) else {
throw ACPClientError.encodingFailed
}
logger.debug("Sending: \(method) (id: \(requestId))")
// session/prompt streams events and can run for minutes no hard timeout.
// Control messages get a 30s watchdog.
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
Task { [weak self] in
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
await self?.timeoutRequest(id: requestId, method: method)
}
} else {
nil
}
defer { timeoutTask?.cancel() }
let fd = stdinFd
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
pendingRequests[requestId] = continuation
guard fd >= 0 else {
pendingRequests.removeValue(forKey: requestId)
continuation.resume(throwing: ACPClientError.notConnected)
return
}
var payload = data
payload.append(contentsOf: "\n".utf8)
// Write in a detached task to avoid blocking the actor's executor.
// The continuation is already stored; the response arrives via the read loop.
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload)
if !ok {
await self?.handleWriteFailedForRequest(id: requestId)
}
}
}
}
private func timeoutRequest(id: Int, method: String) {
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
logger.error("Request timed out: \(method) (id: \(id))")
statusMessage = "Request timed out"
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
}
private func writeJSON(_ dict: [String: Any]) {
let fd = stdinFd
guard fd >= 0,
let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
var payload = data
payload.append(contentsOf: "\n".utf8)
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload)
if !ok {
await self?.handleWriteFailed()
}
}
}
// MARK: - Read Loop
private func startReadLoop(stdout: Pipe, stderr: Pipe) {
// Read stdout for JSON-RPC messages
readTask = Task.detached { [weak self] in
let handle = stdout.fileHandleForReading
var buffer = Data()
while !Task.isCancelled {
let chunk = handle.availableData
if chunk.isEmpty { break } // EOF
buffer.append(chunk)
while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(buffer[buffer.startIndex..<newlineIndex])
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
guard !lineData.isEmpty else { continue }
if let lineStr = String(data: lineData, encoding: .utf8) {
self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
}
do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
await self?.handleMessage(message)
} catch {
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
}
}
}
await self?.handleReadLoopEnded()
}
// Read stderr in background for diagnostic logging AND ring-buffer
// capture so we can attach a tail to user-visible errors.
stderrTask = Task.detached { [weak self] in
let handle = stderr.fileHandleForReading
while !Task.isCancelled {
let data = handle.availableData
if data.isEmpty { break }
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
self?.logger.info("ACP stderr: \(text.prefix(500))")
await self?.appendStderr(text)
}
}
}
}
private func handleMessage(_ message: ACPRawMessage) {
if message.isResponse {
if let requestId = message.id,
let continuation = pendingRequests.removeValue(forKey: requestId) {
if let error = message.error {
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
statusMessage = "Error: \(error.message)"
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
} else {
logger.debug("ACP response (id: \(requestId))")
continuation.resume(returning: message.result)
}
} else {
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
}
} else if message.isNotification {
if let event = ACPEventParser.parse(notification: message) {
logger.debug("ACP event: \(String(describing: event).prefix(100))")
eventContinuation?.yield(event)
}
} else if message.isRequest {
if message.method == "session/request_permission",
let event = ACPEventParser.parsePermissionRequest(message) {
statusMessage = "Permission required"
eventContinuation?.yield(event)
}
}
}
// MARK: - Disconnect Cleanup
/// Single idempotent cleanup path for all disconnect scenarios.
private func performDisconnectCleanup(reason: String) {
guard isConnected else { return }
logger.warning("ACP disconnecting: \(reason)")
isConnected = false
statusMessage = "Connection lost"
for (_, continuation) in pendingRequests {
continuation.resume(throwing: ACPClientError.processTerminated)
}
pendingRequests.removeAll()
eventContinuation?.finish()
eventContinuation = nil
}
private func handleReadLoopEnded() {
performDisconnectCleanup(reason: "read loop ended (EOF)")
}
private func handleTermination(exitCode: Int32) {
performDisconnectCleanup(reason: "process exited (\(exitCode))")
}
private func handleWriteFailed() {
performDisconnectCleanup(reason: "write failed (broken pipe)")
}
private func handleWriteFailedForRequest(id: Int) {
if let continuation = pendingRequests.removeValue(forKey: id) {
continuation.resume(throwing: ACPClientError.processTerminated)
}
performDisconnectCleanup(reason: "write failed (broken pipe)")
}
// MARK: - Safe POSIX Write
/// Write data to a file descriptor using POSIX write(), returning false on error.
/// Handles partial writes and returns false on EPIPE or other errors.
private static func safeWrite(fd: Int32, data: Data) -> Bool {
data.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return false }
var written = 0
let total = buf.count
while written < total {
let result = Darwin.write(fd, base.advanced(by: written), total - written)
if result <= 0 { return false }
written += result
}
return true
}
}
}
// MARK: - Errors
enum ACPClientError: Error, LocalizedError {
case notConnected
case encodingFailed
case invalidResponse(String)
case rpcError(code: Int, message: String)
case processTerminated
case requestTimeout(method: String)
var errorDescription: String? {
switch self {
case .notConnected: return "ACP client is not connected"
case .encodingFailed: return "Failed to encode JSON-RPC request"
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
case .processTerminated: return "ACP process terminated unexpectedly"
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
}
}
}
/// Maps a raw error message (RPC message or captured stderr) to a short
/// human-readable hint for the chat UI. Pattern-matches the most common
/// fresh-install failure modes. Returns nil when no known pattern matches.
enum ACPErrorHint {
static func classify(errorMessage: String, stderrTail: String) -> String? {
let haystack = errorMessage + "\n" + stderrTail
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil
|| haystack.contains("ANTHROPIC_API_KEY")
|| haystack.contains("ANTHROPIC_TOKEN")
|| haystack.contains("claude setup-token")
|| haystack.contains("claude /login") {
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
}
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
options: .regularExpression) {
let matched = String(haystack[match])
if let nameStart = matched.range(of: "'"),
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
}
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
}
if haystack.localizedCaseInsensitiveContains("rate limit")
|| haystack.localizedCaseInsensitiveContains("429") {
return "Your AI provider returned a rate-limit error. Try again in a moment."
}
return nil
}
}
+371 -82
View File
@@ -1,22 +1,151 @@
import Foundation import Foundation
import SQLite3 import SQLite3
import os
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
/// can all ask for a fresh snapshot within the same millisecond without
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
/// round-trip, three parallel backups of the same DB. Callers in flight for
/// the same `ServerID` await the first caller's Task and share its result.
actor SnapshotCoordinator {
static let shared = SnapshotCoordinator()
private var inFlight: [ServerID: Task<URL, Error>] = [:]
func snapshot(
remotePath: String,
contextID: ServerID,
transport: any ServerTransport
) async throws -> URL {
if let existing = inFlight[contextID] {
return try await existing.value
}
let task = Task<URL, Error> {
try transport.snapshotSQLite(remotePath: remotePath)
}
inFlight[contextID] = task
defer { inFlight[contextID] = nil }
return try await task.value
}
}
actor HermesDataService { actor HermesDataService {
private var db: OpaquePointer? private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
func open() -> Bool { private var db: OpaquePointer?
let path = HermesPaths.stateDB private var hasV07Schema = false
guard FileManager.default.fileExists(atPath: path) else { return false } /// Local filesystem path we last opened. For remote contexts this is
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX /// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
let result = sqlite3_open_v2(path, &db, flags, nil) private var openedAtPath: String?
/// Last error from `open()` / `refresh()`, user-presentable. `nil` means
/// the last attempt succeeded. Views surface this when their own load
/// path fails, so the user sees "Permission denied reading state.db"
/// instead of an empty Dashboard with no explanation.
private(set) var lastOpenError: String?
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
func open() async -> Bool {
if db != nil { return true }
let localPath: String
if context.isRemote {
// Pull a fresh snapshot from the remote host. Uses `sqlite3
// .backup` on the remote, which is WAL-safe; a plain cp would
// corrupt. Routed through SnapshotCoordinator so concurrent
// view models don't each spawn a parallel SSH backup for the
// same server.
do {
let url = try await SnapshotCoordinator.shared.snapshot(
remotePath: context.paths.stateDB,
contextID: context.id,
transport: transport
)
localPath = url.path
lastOpenError = nil
} catch {
lastOpenError = humanize(error)
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
return false
}
} else {
localPath = context.paths.stateDB
guard FileManager.default.fileExists(atPath: localPath) else {
lastOpenError = "Hermes state database not found at \(localPath)."
return false
}
}
// Remote snapshots are point-in-time copies that no one writes to;
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
// locking entirely, which is both faster and avoids spurious
// "unable to open database file" errors if the snapshot ever gets
// pulled mid-checkpoint. Local points at the live Hermes DB where
// the process already has WAL enabled in the header, so a plain
// readonly open is the right thing.
let flags: Int32
let openPath: String
if context.isRemote {
openPath = "file:\(localPath)?immutable=1"
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI
} else {
openPath = localPath
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
}
let result = sqlite3_open_v2(openPath, &db, flags, nil)
guard result == SQLITE_OK else { guard result == SQLITE_OK else {
let msg: String
if let db {
msg = String(cString: sqlite3_errmsg(db))
} else {
msg = "sqlite3_open_v2 returned \(result)"
}
lastOpenError = "Couldn't open state.db: \(msg)"
Self.logger.warning("sqlite3_open_v2 failed (\(result)) at \(localPath, privacy: .public): \(msg, privacy: .public)")
db = nil db = nil
return false return false
} }
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil) openedAtPath = localPath
lastOpenError = nil
detectSchema()
return true return true
} }
/// Turn a transport error into the one-line string Dashboard shows. Adds
/// hints for the common "sqlite3 not installed" and "permission denied"
/// cases so users know what to do.
private nonisolated func humanize(_ error: Error) -> String {
let desc = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
let lower = desc.lowercased()
if lower.contains("sqlite3: command not found") || lower.contains("sqlite3: not found") {
return "sqlite3 is not installed on \(context.displayName). Install it with `apt install sqlite3` (Ubuntu/Debian) or `yum install sqlite` (RHEL/Fedora)."
}
if lower.contains("permission denied") {
return "Permission denied reading Hermes state on \(context.displayName). The SSH user may not have read access to ~/.hermes/state.db — try Run Diagnostics."
}
if lower.contains("no such file") {
return "Hermes state not found at ~/.hermes on \(context.displayName). If Hermes is installed elsewhere, set its data directory in Manage Servers."
}
return desc
}
/// Force a fresh snapshot pull + reopen. Used on session-load and in
/// any path that needs the UI to reflect writes Hermes just made.
/// Without this, remote snapshots would be frozen at the first `open()`
/// for the app's lifetime new messages added to a resumed session
/// would never appear because the snapshot was pulled before they were
/// written. Local contexts pay essentially nothing: close+reopen on a
/// live DB is a no-op.
@discardableResult
func refresh() async -> Bool {
close()
return await open()
}
func close() { func close() {
if let db { if let db {
sqlite3_close(db) sqlite3_close(db)
@@ -24,17 +153,39 @@ actor HermesDataService {
db = nil db = nil
} }
func fetchSessions(limit: Int = 100) -> [HermesSession] { // MARK: - Schema Detection
guard let db else { return [] }
let sql = """ private func detectSchema() {
SELECT id, source, user_id, model, title, parent_session_id, 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, started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd estimated_cost_usd
FROM sessions
ORDER BY started_at DESC
LIMIT ?
""" """
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? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
@@ -47,19 +198,56 @@ actor HermesDataService {
return sessions return sessions
} }
func fetchMessages(sessionId: String) -> [HermesMessage] { func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] } guard let db else { return [] }
let sql = """ let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
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
"""
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } 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] = [] var messages: [HermesMessage] = []
while sqlite3_step(stmt) == SQLITE_ROW { while sqlite3_step(stmt) == SQLITE_ROW {
@@ -68,11 +256,15 @@ actor HermesDataService {
return messages return messages
} }
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] { func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
guard let db else { return [] } 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 = """ let sql = """
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, SELECT \(msgCols)
m.tool_name, m.timestamp, m.token_count, m.finish_reason
FROM messages_fts fts FROM messages_fts fts
JOIN messages m ON m.id = fts.rowid JOIN messages m ON m.id = fts.rowid
WHERE messages_fts MATCH ? WHERE messages_fts MATCH ?
@@ -82,7 +274,7 @@ actor HermesDataService {
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } 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)) sqlite3_bind_int(stmt, 2, Int32(limit))
var messages: [HermesMessage] = [] var messages: [HermesMessage] = []
@@ -92,11 +284,21 @@ actor HermesDataService {
return messages 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 [] } guard let db else { return [] }
let sql = """ let sql = """
SELECT id, session_id, role, content, tool_call_id, tool_calls, SELECT \(messageColumns)
tool_name, timestamp, token_count, finish_reason
FROM messages FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != '' WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC ORDER BY timestamp DESC
@@ -114,10 +316,10 @@ actor HermesDataService {
return messages return messages
} }
func fetchSessionPreviews(limit: Int = 10) -> [String: String] { func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
guard let db else { return [:] } guard let db else { return [:] }
let sql = """ let sql = """
SELECT m.session_id, substr(m.content, 1, 100) SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
FROM messages m FROM messages m
INNER JOIN ( INNER JOIN (
SELECT session_id, MIN(id) as min_id SELECT session_id, MIN(id) as min_id
@@ -142,6 +344,83 @@ actor HermesDataService {
return previews 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 { struct SessionStats: Sendable {
let totalSessions: Int let totalSessions: Int
let totalMessages: Int let totalMessages: Int
@@ -149,71 +428,59 @@ actor HermesDataService {
let totalInputTokens: Int let totalInputTokens: Int
let totalOutputTokens: Int let totalOutputTokens: Int
let totalCostUSD: Double 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 { func fetchStats() -> SessionStats {
guard let db else { guard let db else { return .empty }
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, let sql: String
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) if hasV07Schema {
} sql = """
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),
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), 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(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0) COALESCE(SUM(estimated_cost_usd),0)
FROM sessions FROM sessions
""" """
}
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
guard sqlite3_step(stmt) == SQLITE_ROW else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
return SessionStats( return SessionStats(
totalSessions: Int(sqlite3_column_int(stmt, 0)), totalSessions: Int(sqlite3_column_int(stmt, 0)),
totalMessages: Int(sqlite3_column_int(stmt, 1)), totalMessages: Int(sqlite3_column_int(stmt, 1)),
totalToolCalls: Int(sqlite3_column_int(stmt, 2)), totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
totalInputTokens: Int(sqlite3_column_int(stmt, 3)), totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)), 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 // MARK: - Insights Queries
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] }
let sql = """
SELECT id, source, user_id, model, title, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd
FROM sessions
WHERE started_at >= ?
ORDER BY started_at DESC
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
func fetchUserMessageCount(since: Date) -> Int { func fetchUserMessageCount(since: Date) -> Int {
guard let db else { return 0 } guard let db else { return 0 }
let sql = """ let sql = """
SELECT COUNT(*) FROM messages m SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.started_at >= ? WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
@@ -229,7 +496,7 @@ actor HermesDataService {
SELECT m.tool_name, COUNT(*) as cnt SELECT m.tool_name, COUNT(*) as cnt
FROM messages m FROM messages m
JOIN sessions s ON m.session_id = s.id JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ? WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
GROUP BY m.tool_name GROUP BY m.tool_name
ORDER BY cnt DESC ORDER BY cnt DESC
""" """
@@ -250,7 +517,7 @@ actor HermesDataService {
func fetchSessionStartHours(since: Date) -> [Int: Int] { func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] } guard let db else { return [:] }
let sql = """ let sql = """
SELECT started_at FROM sessions WHERE started_at >= ? SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -271,7 +538,7 @@ actor HermesDataService {
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] { func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] } guard let db else { return [:] }
let sql = """ let sql = """
SELECT started_at FROM sessions WHERE started_at >= ? SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -290,11 +557,10 @@ actor HermesDataService {
} }
func stateDBModificationDate() -> Date? { func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal" // For remote contexts we stat the remote paths. For local it's the
let dbPath = HermesPaths.stateDB // same FileManager lookup as before, just via the transport.
let fm = FileManager.default let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date let dbDate = transport.stat(context.paths.stateDB)?.mtime
let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date
if let w = walDate, let d = dbDate { if let w = walDate, let d = dbDate {
return max(w, d) return max(w, d)
} }
@@ -320,7 +586,11 @@ actor HermesDataService {
outputTokens: Int(sqlite3_column_int(stmt, 12)), outputTokens: Int(sqlite3_column_int(stmt, 12)),
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)), cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)), cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
) )
} }
@@ -337,14 +607,20 @@ actor HermesDataService {
toolName: columnOptionalText(stmt, 6), toolName: columnOptionalText(stmt, 6),
timestamp: columnDate(stmt, 7), timestamp: columnDate(stmt, 7),
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, 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] { private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
guard let json, !json.isEmpty, guard let json, !json.isEmpty,
let data = json.data(using: .utf8) else { return [] } 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 { private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
@@ -365,4 +641,17 @@ actor HermesDataService {
let value = sqlite3_column_double(stmt, col) let value = sqlite3_column_double(stmt, col)
return Date(timeIntervalSince1970: value) return Date(timeIntervalSince1970: value)
} }
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
private func sanitizeFTSQuery(_ raw: String) -> String {
raw.split(separator: " ")
.map { token in
let t = String(token)
let stripped = t.replacingOccurrences(of: "\"", with: "")
return stripped.isEmpty ? nil : "\"\(stripped)\""
}
.compactMap { $0 }
.joined(separator: " ")
}
} }
@@ -0,0 +1,216 @@
import Foundation
import os
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
/// ordering of keys we don't touch.
///
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
/// and optional double-quoted values for strings with spaces or special chars.
/// We do NOT attempt to implement full shell-style escaping; the fields we write
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags none of which
/// contain characters needing escaping beyond double-quoting.
///
/// Design choices:
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
/// than deleting it, so users can restore a key by uncommenting without losing
/// their value.
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
/// written file if Scarf crashes mid-write.
/// - **Never logs values**: secrets flow through this service.
struct HermesEnvService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
/// Path to `~/.hermes/.env`. Kept configurable for tests.
let path: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.path = context.paths.envFile
self.transport = context.makeTransport()
}
/// Escape hatch for tests that want to point at a fixture path directly.
init(path: String) {
self.path = path
self.transport = LocalTransport()
}
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
/// assignments are ignored. Missing file returns an empty dict.
func load() -> [String: String] {
guard let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) else {
return [:]
}
var result: [String: String] = [:]
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip blanks and comments. A line beginning with `#` is either a pure
// comment or a disabled assignment both should be treated as "unset".
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
guard let eq = trimmed.firstIndex(of: "=") else { continue }
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
result[key] = Self.stripEnvQuotes(raw)
}
return result
}
func get(_ key: String) -> String? {
load()[key]
}
/// Write/update a single key. Preserves the position of existing assignments
/// (even if they were commented out the new assignment replaces the comment
/// line in place). New keys are appended at the end.
@discardableResult
func set(_ key: String, value: String) -> Bool {
setMany([key: value])
}
/// Update multiple keys in one atomic rewrite. Use this when a form saves
/// several fields at once so the file doesn't get repeatedly rewritten.
///
/// Returns `true` on success, `false` if the atomic rewrite failed.
@discardableResult
func setMany(_ pairs: [String: String]) -> Bool {
var remaining = pairs
var lines: [String]
// Start from existing file contents, or a minimal header if creating new.
if let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) {
lines = content.components(separatedBy: "\n")
// Trim a single trailing empty line from splitting the final newline;
// we'll re-add it on write.
if lines.last == "" { lines.removeLast() }
} else {
lines = ["# Hermes Agent Environment Configuration"]
}
// First pass: update in-place (handles both live and commented-out lines).
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line) else { continue }
if let newValue = remaining.removeValue(forKey: match.key) {
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
lines[idx] = Self.formatLine(key: match.key, value: newValue)
}
}
// Second pass: append any keys that didn't match an existing line.
if !remaining.isEmpty {
// Leave a blank line before appending new keys for visual separation.
if let last = lines.last, !last.isEmpty {
lines.append("")
}
for key in remaining.keys.sorted() {
lines.append(Self.formatLine(key: key, value: remaining[key]!))
}
}
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
/// Comment out a key. The value is preserved so the user can restore by
/// uncommenting. If the key doesn't exist, this is a no-op.
@discardableResult
func unset(_ key: String) -> Bool {
guard let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) else {
return true
}
var lines = content.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() }
var changed = false
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
// Skip lines that are already commented nothing to do.
if Self.isCommentedOutAssignment(line) { continue }
lines[idx] = "# " + line
changed = true
}
guard changed else { return true }
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
// MARK: - Internals
/// Writes the entire file in one shot through the transport. For local
/// contexts this ends up doing the same atomic-rename dance as before
/// (via `LocalTransport.writeFile`). For remote contexts this goes
/// through `scp` + remote `mv`, still atomic from Hermes's point of
/// view.
private func atomicWrite(_ content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false }
do {
try transport.writeFile(path, data: data)
return true
} catch {
logger.error("Failed to write .env: \(error.localizedDescription)")
return false
}
}
/// Extract a key name and whether the line was active or commented-out.
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
var work = line.trimmingCharacters(in: .whitespaces)
var active = true
if work.hasPrefix("#") {
active = false
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
}
guard let eq = work.firstIndex(of: "=") else { return nil }
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
// Reject non-identifier looking keys to avoid matching prose in comments
// (e.g. "# This is a note about something = nice").
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
return nil
}
return (key, active)
}
private static func isCommentedOutAssignment(_ line: String) -> Bool {
guard let match = extractKey(fromLine: line) else { return false }
return !match.active
}
/// Format a single `KEY=value` line. Values containing whitespace or shell
/// metacharacters get double-quoted; simple tokens go in unquoted to match
/// hermes's own output style.
private static func formatLine(key: String, value: String) -> String {
if Self.needsQuoting(value) {
// Escape embedded backslashes and double quotes, then wrap.
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\(key)=\"\(escaped)\""
}
return "\(key)=\(value)"
}
private static func needsQuoting(_ value: String) -> Bool {
if value.isEmpty { return false }
// Whitespace, shell metacharacters, or quotes trigger quoting.
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
return value.contains(where: { metacharacters.contains($0) })
}
/// Strip one layer of matched double or single quotes from a loaded value.
private static func stripEnvQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
var inner = String(s.dropFirst().dropLast())
if first == "\"" {
inner = inner
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
}
return inner
}
return s
}
}
File diff suppressed because it is too large Load Diff
@@ -6,30 +6,66 @@ final class HermesFileWatcher {
private var coreSources: [DispatchSourceFileSystemObject] = [] private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = [] private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer? private var timer: Timer?
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
/// on `stopWatching()`.
private var remotePollTask: Task<Void, Never>?
let context: ServerContext
private let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
/// Canonical list of paths we observe. Used for both FSEvents (local)
/// and mtime polling (remote).
private var watchedCorePaths: [String] {
let paths = context.paths
return [
paths.stateDB,
paths.stateDB + "-wal",
paths.configYAML,
paths.home + "/.env",
paths.memoryMD,
paths.userMD,
paths.cronJobsJSON,
paths.gatewayStateJSON,
paths.agentLog,
paths.errorsLog,
paths.gatewayLog,
paths.projectsRegistry,
paths.mcpTokensDir
]
}
func startWatching() { func startWatching() {
let paths = [ if context.isRemote {
HermesPaths.stateDB, // FSEvents doesn't reach across SSH. Drive lastChangeDate off
HermesPaths.stateDB + "-wal", // the transport's AsyncStream, which polls stat mtime on a
HermesPaths.configYAML, // shared ControlMaster channel (~5ms per tick).
HermesPaths.memoryMD, let stream = transport.watchPaths(watchedCorePaths)
HermesPaths.userMD, remotePollTask = Task { [weak self] in
HermesPaths.cronJobsJSON, for await _ in stream {
HermesPaths.gatewayStateJSON, await MainActor.run { [weak self] in
HermesPaths.errorsLog, self?.lastChangeDate = Date()
HermesPaths.gatewayLog, }
HermesPaths.projectsRegistry }
] }
return
}
for path in paths { for path in watchedCorePaths {
if let source = makeSource(for: path) { if let source = makeSource(for: path) {
coreSources.append(source) coreSources.append(source)
} }
} }
// No heartbeat timer: every observing view runs its `.onChange`
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in // refresh whenever `lastChangeDate` ticks, so a 5s unconditional
self?.lastChangeDate = Date() // tick was triggering wasted reloads across many subscribers
} // (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat).
// FSEvents reliably fires on real changes; menu-bar Start/Stop
// touches `gateway_state.json` which the watcher catches.
} }
func stopWatching() { func stopWatching() {
@@ -40,9 +76,15 @@ final class HermesFileWatcher {
projectSources.removeAll() projectSources.removeAll()
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
remotePollTask?.cancel()
remotePollTask = nil
} }
func updateProjectWatches(_ dashboardPaths: [String]) { func updateProjectWatches(_ dashboardPaths: [String]) {
// Remote contexts don't support per-project FSEvents watches today
// the shared mtime poll covers the core set. Adding per-project
// polling is a Phase 4 polish item.
guard !context.isRemote else { return }
for source in projectSources { for source in projectSources {
source.cancel() source.cancel()
} }
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
let id: Int let id: Int
let timestamp: String let timestamp: String
let level: LogLevel let level: LogLevel
let sessionId: String?
let logger: String let logger: String
let message: String let message: String
let raw: String let raw: String
@@ -32,21 +33,79 @@ actor HermesLogService {
private var currentPath: String? private var currentPath: String?
private var entryCounter = 0 private var entryCounter = 0
/// Remote tailing state. When set, we're reading from `ssh host tail -F`
/// instead of a local file. Process stdout pipe drives `readNewLines()`;
/// process lifecycle is the actor's responsibility.
private var remoteTailProcess: Process?
private var remoteTailBuffer: String = ""
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
func openLog(path: String) { func openLog(path: String) {
closeLog() closeLog()
currentPath = path currentPath = path
if context.isRemote {
// Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F`
// follows the file through rotations important for remote
// log rotation setups (logrotate).
let proc = transport.makeProcess(
executable: "/usr/bin/tail",
args: ["-n", String(QueryDefaults.logLineLimit), "-F", path]
)
let outPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = Pipe()
do {
try proc.run()
remoteTailProcess = proc
fileHandle = outPipe.fileHandleForReading
} catch {
print("[Scarf] Failed to start remote tail: \(error.localizedDescription)")
remoteTailProcess = nil
fileHandle = nil
}
} else {
fileHandle = FileHandle(forReadingAtPath: path) fileHandle = FileHandle(forReadingAtPath: path)
} }
}
func closeLog() { func closeLog() {
try? fileHandle?.close() do {
try fileHandle?.close()
} catch {
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
}
fileHandle = nil fileHandle = nil
currentPath = nil currentPath = nil
if let proc = remoteTailProcess, proc.isRunning {
proc.terminate()
}
remoteTailProcess = nil
remoteTailBuffer = ""
} }
func readLastLines(count: Int = 200) -> [LogEntry] { func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
guard let path = currentPath, guard let path = currentPath else { return [] }
let data = FileManager.default.contents(atPath: path) else { return [] } if context.isRemote {
// For the initial load we bypass the streaming tail and run a
// one-shot `tail -n <count>` for a clean bounded read.
let result = try? transport.runProcess(
executable: "/usr/bin/tail",
args: ["-n", String(count), path],
stdin: nil,
timeout: 30
)
let content = result?.stdoutString ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) }
}
guard let data = FileManager.default.contents(atPath: path) else { return [] }
let content = String(data: data, encoding: .utf8) ?? "" let content = String(data: data, encoding: .utf8) ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
let lastLines = Array(lines.suffix(count)) let lastLines = Array(lines.suffix(count))
@@ -57,34 +116,57 @@ actor HermesLogService {
guard let handle = fileHandle else { return [] } guard let handle = fileHandle else { return [] }
let data = handle.availableData let data = handle.availableData
guard !data.isEmpty else { return [] } guard !data.isEmpty else { return [] }
let content = String(data: data, encoding: .utf8) ?? "" let chunk = String(data: data, encoding: .utf8) ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } if context.isRemote {
// Remote tail emits bytes as they arrive not line-aligned.
// Buffer partials across reads so we don't split a line mid-way.
remoteTailBuffer += chunk
guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else {
return []
}
let complete = String(remoteTailBuffer[..<lastNewline])
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) }
}
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) } return lines.map { parseLine($0) }
} }
func seekToEnd() { func seekToEnd() {
// Only meaningful for local FileHandles remote tail starts at the
// end implicitly after `readLastLines` drained the initial load.
if !context.isRemote {
fileHandle?.seekToEndOfFile() fileHandle?.seekToEndOfFile()
} }
}
private func parseLine(_ line: String) -> LogEntry { private func parseLine(_ line: String) -> LogEntry {
entryCounter += 1 entryCounter += 1
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message // Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] 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+(.*)$"# // 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), if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
let timestamp = String(line[Range(match.range(at: 1), 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 levelStr = String(line[Range(match.range(at: 2), in: line)!])
let logger = String(line[Range(match.range(at: 3), in: line)!]) let sessionId: String? = {
let message = String(line[Range(match.range(at: 4), in: line)!]) 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( return LogEntry(
id: entryCounter, id: entryCounter,
timestamp: timestamp, timestamp: timestamp,
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info, level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
sessionId: sessionId,
logger: logger, logger: logger,
message: message, message: message,
raw: line 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,209 @@
import Foundation
import os
/// A single model from the models.dev catalog shipped with hermes.
struct HermesModelInfo: Sendable, Identifiable, Hashable {
var id: String { providerID + ":" + modelID }
let providerID: String
let providerName: String
let modelID: String
let modelName: String
let contextWindow: Int?
let maxOutput: Int?
let costInput: Double? // USD per 1M input tokens
let costOutput: Double? // USD per 1M output tokens
let reasoning: Bool
let toolCall: Bool
let releaseDate: String?
/// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil }
return String(format: "$%.2f / $%.2f", input, output)
}
/// Display-friendly context window ("200K", "1M", etc.).
var contextDisplay: String? {
guard let ctx = contextWindow else { return nil }
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
return "\(ctx)"
}
}
/// Provider summary one row in the left column of the picker.
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
var id: String { providerID }
let providerID: String
let providerName: String
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
let docURL: String?
let modelCount: Int
}
/// Reads the models.dev catalog that hermes caches at
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
/// call (~1500 models across ~110 providers).
///
/// We decode a trimmed subset so unknown fields don't break loading. Every
/// field we care about is optional on disk providers may omit cost, context
/// limits, etc.
struct ModelCatalogService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
let path: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.path = context.paths.home + "/models_dev_cache.json"
self.transport = context.makeTransport()
}
/// Escape hatch for tests.
init(path: String) {
self.path = path
self.transport = LocalTransport()
}
/// All providers, sorted by display name.
func loadProviders() -> [HermesProviderInfo] {
guard let catalog = loadCatalog() else { return [] }
return catalog
.map { (id, p) in
HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
}
/// Models for one provider, sorted by release date (newest first), then name.
func loadModels(for providerID: String) -> [HermesModelInfo] {
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
let providerName = provider.name ?? providerID
let models = (provider.models ?? [:]).map { (id, m) in
HermesModelInfo(
providerID: providerID,
providerName: providerName,
modelID: id,
modelName: m.name ?? id,
contextWindow: m.limit?.context,
maxOutput: m.limit?.output,
costInput: m.cost?.input,
costOutput: m.cost?.output,
reasoning: m.reasoning ?? false,
toolCall: m.tool_call ?? false,
releaseDate: m.release_date
)
}
return models.sorted { lhs, rhs in
// Newest-first by release date if both are known; otherwise fall
// back to alphabetical on display name.
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
return lDate > rDate
}
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
}
}
/// Find the provider that ships a given model ID. Useful for auto-syncing
/// provider when the user picks a model from a flat list or types one in.
func provider(for modelID: String) -> HermesProviderInfo? {
guard let catalog = loadCatalog() else { return nil }
for (providerID, p) in catalog {
if p.models?[modelID] != nil {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
}
// Handle provider-prefixed IDs like "openai/gpt-4o" look up the
// prefix before the slash.
if let slash = modelID.firstIndex(of: "/") {
let prefix = String(modelID[modelID.startIndex..<slash])
if let p = catalog[prefix] {
return HermesProviderInfo(
providerID: prefix,
providerName: p.name ?? prefix,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
}
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model).
func model(providerID: String, modelID: String) -> HermesModelInfo? {
guard let catalog = loadCatalog(),
let provider = catalog[providerID],
let raw = provider.models?[modelID] else { return nil }
return HermesModelInfo(
providerID: providerID,
providerName: provider.name ?? providerID,
modelID: modelID,
modelName: raw.name ?? modelID,
contextWindow: raw.limit?.context,
maxOutput: raw.limit?.output,
costInput: raw.cost?.input,
costOutput: raw.cost?.output,
reasoning: raw.reasoning ?? false,
toolCall: raw.tool_call ?? false,
releaseDate: raw.release_date
)
}
// MARK: - Decoding
private func loadCatalog() -> [String: ProviderEntry]? {
guard let data = try? transport.readFile(path) else {
return nil
}
do {
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
} catch {
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
return nil
}
}
// Trimmed representations we decode a subset of fields and tolerate
// anything new hermes adds later. `snake_case` field names match the file.
private struct ProviderEntry: Decodable {
let id: String?
let name: String?
let env: [String]?
let doc: String?
let models: [String: ModelEntry]?
}
private struct ModelEntry: Decodable {
let name: String?
let reasoning: Bool?
let tool_call: Bool?
let release_date: String?
let cost: CostEntry?
let limit: LimitEntry?
}
private struct CostEntry: Decodable {
let input: Double?
let output: Double?
}
private struct LimitEntry: Decodable {
let context: Int?
let output: Int?
}
}
@@ -2,48 +2,69 @@ import Foundation
struct ProjectDashboardService: Sendable { struct ProjectDashboardService: Sendable {
let context: ServerContext
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
// MARK: - Registry // MARK: - Registry
func loadRegistry() -> ProjectRegistry { func loadRegistry() -> ProjectRegistry {
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else { guard let data = try? transport.readFile(context.paths.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: []) return ProjectRegistry(projects: [])
} }
return (try? JSONDecoder().decode(ProjectRegistry.self, from: data))
?? ProjectRegistry(projects: [])
} }
func saveRegistry(_ registry: ProjectRegistry) { func saveRegistry(_ registry: ProjectRegistry) {
let dir = HermesPaths.scarfDir let dir = context.paths.scarfDir
if !FileManager.default.fileExists(atPath: dir) { if !transport.fileExists(dir) {
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) do {
try transport.createDirectory(dir)
} catch {
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
return
}
} }
guard let data = try? JSONEncoder().encode(registry) else { return } guard let data = try? JSONEncoder().encode(registry) else { return }
// Pretty-print for readability (agents may read this file) // Pretty-print for readability (agents may read this file)
let writeData: Data
if let pretty = try? JSONSerialization.jsonObject(with: data), if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) { let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted) writeData = formatted
} else { } else {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data) writeData = data
} }
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
} }
// MARK: - Dashboard // MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? { func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else { guard let data = try? transport.readFile(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 return nil
} }
return try? JSONDecoder().decode(ProjectDashboard.self, from: data)
} }
func dashboardExists(for project: ProjectEntry) -> Bool { func dashboardExists(for project: ProjectEntry) -> Bool {
FileManager.default.fileExists(atPath: project.dashboardPath) transport.fileExists(project.dashboardPath)
} }
func dashboardModificationDate(for project: ProjectEntry) -> Date? { func dashboardModificationDate(for project: ProjectEntry) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else { transport.stat(project.dashboardPath)?.mtime
return nil
}
return attrs[.modificationDate] as? Date
} }
} }
@@ -0,0 +1,40 @@
import Foundation
import Sparkle
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
///
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
/// checks, and observable state for the Settings screen.
@MainActor
@Observable
final class UpdaterService: NSObject {
private let controller: SPUStandardUpdaterController
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
var automaticallyChecksForUpdates: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set { controller.updater.automaticallyChecksForUpdates = newValue }
}
/// Last time Sparkle checked the appcast (nil before the first check).
var lastUpdateCheckDate: Date? {
controller.updater.lastUpdateCheckDate
}
override init() {
// startingUpdater: true Sparkle scans for updates on launch per Info.plist schedule.
// Default delegates are sufficient for a non-sandboxed app.
self.controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
super.init()
}
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}
@@ -0,0 +1,191 @@
import Foundation
import os
/// `ServerTransport` over the local filesystem. Thin wrapper around
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` the APIs
/// services were already using before Phase 2.
struct LocalTransport: ServerTransport {
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport")
let contextID: ServerID
let isRemote: Bool = false
nonisolated init(contextID: ServerID = ServerContext.local.id) {
self.contextID = contextID
}
// MARK: - Files
func readFile(_ path: String) throws -> Data {
do {
return try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func writeFile(_ path: String, data: Data) throws {
let tmp = path + ".scarf.tmp"
do {
try data.write(to: URL(fileURLWithPath: tmp))
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
// The existing files already use 0600 via HermesEnvService; we
// mirror that here so a brand-new file created via this write
// also starts with safe permissions.
if Self.shouldEnforcePrivateMode(for: path) {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
}
// Atomic swap onto the final path.
let destURL = URL(fileURLWithPath: path)
let tmpURL = URL(fileURLWithPath: tmp)
if FileManager.default.fileExists(atPath: path) {
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
// Ensure parent exists.
let parent = (path as NSString).deletingLastPathComponent
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try FileManager.default.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? FileManager.default.removeItem(atPath: tmp)
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func fileExists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path)
}
func stat(_ path: String) -> FileStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
return nil
}
let size = (attrs[.size] as? Int64) ?? Int64((attrs[.size] as? Int) ?? 0)
let mtime = (attrs[.modificationDate] as? Date) ?? Date(timeIntervalSince1970: 0)
let isDir = (attrs[.type] as? FileAttributeType) == .typeDirectory
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
}
func listDirectory(_ path: String) throws -> [String] {
do {
return try FileManager.default.contentsOfDirectory(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func createDirectory(_ path: String) throws {
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func removeFile(_ path: String) throws {
guard FileManager.default.fileExists(atPath: path) else { return }
do {
try FileManager.default.removeItem(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
// MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
// Timeout handling: poll every 100ms up to timeout, kill on overrun.
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
func makeProcess(executable: String, args: [String]) -> Process {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
return proc
}
// MARK: - SQLite
func snapshotSQLite(remotePath: String) throws -> URL {
// Local case: no copy needed. Services open the path directly.
URL(fileURLWithPath: remotePath)
}
// MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in
// Build the source list immutably, then hand a value-typed copy
// to onTermination. Swift 6's concurrent-capture rule rejects a
// `var sources` shared between the outer builder and the inner
// termination closure.
let sources: [DispatchSourceFileSystemObject] = paths.compactMap { path in
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let src = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename],
queue: .global()
)
src.setEventHandler { continuation.yield(.anyChanged) }
src.setCancelHandler { Darwin.close(fd) }
src.resume()
return src
}
continuation.onTermination = { _ in
for s in sources { s.cancel() }
}
}
}
// MARK: - Helpers
/// Heuristic: files that conventionally hold secrets should be created
/// with restrictive permissions so a future `scp` or editor doesn't end
/// up exposing them.
private static func shouldEnforcePrivateMode(for path: String) -> Bool {
let name = (path as NSString).lastPathComponent
return name == ".env" || name == "auth.json" || name.hasSuffix("-tokens.json")
}
}
@@ -0,0 +1,591 @@
import Foundation
import os
/// `ServerTransport` that reaches a remote Hermes installation through the
/// system `ssh`, `scp`, and `sftp` binaries.
///
/// Why system ssh (not a native library): the user's `~/.ssh/config`,
/// ssh-agent, 1Password/Secretive agents, ProxyJump, and ControlMaster
/// multiplexing all work for free. OpenSSH also owns crypto a smaller
/// audit surface than dragging libssh2 along.
///
/// **ControlMaster matters.** Without it, every remote primitive (stat, cat,
/// cp) authenticates from scratch 500ms-2s per call. With ControlMaster
/// `auto` + `ControlPersist 600`, the first call authenticates, subsequent
/// calls reuse the same TCP/crypto session at ~5ms each. We point the
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
/// windows pointed at the same host share one session cleanly.
struct SSHTransport: ServerTransport {
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport")
let contextID: ServerID
let isRemote: Bool = true
let config: SSHConfig
let displayName: String
nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) {
self.contextID = contextID
self.config = config
self.displayName = displayName
}
// MARK: - ssh/scp binary discovery
nonisolated private var sshBinary: String { "/usr/bin/ssh" }
nonisolated private var scpBinary: String { "/usr/bin/scp" }
/// The fully-qualified `user@host` spec (or just `host` if no user set).
nonisolated private var hostSpec: String {
if let user = config.user, !user.isEmpty { return "\(user)@\(config.host)" }
return config.host
}
/// Absolute path to this server's ControlMaster socket directory. One
/// socket per server, lives under the app's Caches so macOS can sweep it.
nonisolated private var controlDir: String { Self.controlDirPath() }
/// Per-server snapshot cache directory (for SQLite `.backup` drops).
nonisolated private var snapshotDir: String { Self.snapshotDirPath(for: contextID) }
/// Shared control-master socket directory (one dir, sockets within it are
/// per-host via OpenSSH's `%C` token). Exposed as a static so
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
/// compute it without instantiating a transport.
///
/// Uses a short path under /tmp to stay within the 104-byte macOS
/// Unix domain socket limit. The Caches path
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
/// username is long, causing ssh to exit 255.
nonisolated static func controlDirPath() -> String {
return "/tmp/scarf-ssh-\(getuid())"
}
/// Snapshot cache directory for a given server. Stable per-ID so repeated
/// connections to the same server share the cache, and so cleanup can
/// find it from the ID alone.
nonisolated static func snapshotDirPath(for contextID: ServerID) -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots/\(contextID.uuidString)"
}
/// Root of the snapshot cache (all servers). Used by the app-launch sweep
/// that prunes dirs whose UUID no longer appears in the registry.
nonisolated static func snapshotRootPath() -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots"
}
/// Remove the snapshot directory for a server (no-op if absent). Called
/// on `removeServer` and on app-launch for orphaned dirs.
static func pruneSnapshotCache(for contextID: ServerID) {
let dir = snapshotDirPath(for: contextID)
try? FileManager.default.removeItem(atPath: dir)
}
/// Walk the snapshot root and delete any directory whose UUID isn't in
/// `keep`. Called once at app launch so snapshots from servers the user
/// removed while the app was closed don't linger.
static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) {
let root = snapshotRootPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
for name in entries {
if let id = ServerID(uuidString: name), keep.contains(id) { continue }
try? FileManager.default.removeItem(atPath: root + "/" + name)
}
}
/// Remove ControlMaster socket files older than `staleAfter` seconds.
///
/// Socket basenames are %C hashes (not ServerIDs), so we can't keep "still
/// registered" sockets the way `sweepOrphanSnapshots` does. But
/// `ControlPersist` is 600s anything older than 30 minutes is guaranteed
/// to be a dead orphan from a crashed master, an unclean app exit, or a
/// server removed while another Scarf instance was holding the dir.
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
/// indefinitely until reboot, while leaving any concurrent Scarf
/// instance's live sockets (always <600s old) untouched.
static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
let root = controlDirPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
let cutoff = Date().addingTimeInterval(-staleAfter)
for name in entries {
let path = root + "/" + name
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
let mtime = attrs[.modificationDate] as? Date
else { continue }
if mtime < cutoff {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Ask OpenSSH to shut down this host's ControlMaster socket, so the TCP
/// session isn't held open after the user removes this server. If no
/// master is currently running, `ssh -O exit` exits non-zero we ignore
/// the exit code because the desired end state (no master) is reached
/// either way.
func closeControlMaster() {
ensureControlDir()
let args = sshArgs(extra: ["-O", "exit", hostSpec])
_ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10)
}
/// Common ssh options used by every invocation. Keep every `-o` flag
/// here so we never drift between calls.
///
/// - `ControlMaster=auto` + `ControlPersist=600` gives us free connection
/// pooling for the bursty stat/cat/cp traffic the services produce.
/// - `StrictHostKeyChecking=accept-new` writes new hosts to
/// `known_hosts` silently the first time but blocks on key mismatch
/// the UX surfaced by `TransportError.hostKeyMismatch`.
/// - `ServerAliveInterval=30` makes dropped connections surface as a
/// process exit rather than a hang.
/// - `LogLevel=QUIET` suppresses the login banner so ACP's line-delimited
/// JSON stays binary-clean.
nonisolated private func sshArgs(extra: [String] = []) -> [String] {
var args: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes" // Never prompt for passphrases; ssh-agent only.
]
if let port = config.port { args += ["-p", String(port)] }
if let id = config.identityFile, !id.isEmpty {
args += ["-i", id]
}
args += extra
return args
}
/// Ensure the ControlMaster socket directory exists, is a real directory
/// (not a symlink), is owned by us, and has mode 0700. Called before every
/// ssh invocation.
///
/// Defensive against `/tmp` pre-creation: any local user can create
/// `/tmp/scarf-ssh-<uid>` before Scarf launches. Plain `mkdir -p` plus
/// `setAttributes` would silently accept a hostile dir (since the chmod
/// fails when we don't own it, and the Foundation API swallows that). So
/// we use POSIX `mkdir` (atomic, sets perms at create time, doesn't
/// follow symlinks) and `lstat` to verify ownership when the entry
/// already exists.
nonisolated private func ensureControlDir() {
let path = controlDir
let mkResult = path.withCString { mkdir($0, 0o700) }
if mkResult == 0 { return }
let mkErr = errno
if mkErr != EEXIST {
Self.logger.error("Failed to create ControlDir \(path, privacy: .public): errno=\(mkErr)")
return
}
var st = Darwin.stat()
let lstatResult = path.withCString { lstat($0, &st) }
guard lstatResult == 0 else {
Self.logger.error("Could not lstat existing ControlDir \(path, privacy: .public): errno=\(errno)")
return
}
guard (st.st_mode & S_IFMT) == S_IFDIR else {
Self.logger.error("ControlDir \(path, privacy: .public) exists but is not a directory (possibly a symlink) — refusing to use")
return
}
guard st.st_uid == getuid() else {
Self.logger.error("ControlDir \(path, privacy: .public) owned by uid \(st.st_uid), expected \(getuid()) — refusing to use")
return
}
if (st.st_mode & 0o777) != 0o700 {
Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700")
_ = path.withCString { chmod($0, 0o700) }
}
}
/// Shell-quote a single argument for remote execution. The remote shell
/// receives our argv joined with spaces, so anything containing
/// whitespace/metacharacters must be quoted to survive that flattening.
nonisolated private static func shellQuote(_ s: String) -> String {
if s.isEmpty { return "''" }
// Safe subset: alphanumerics + a few shell-inert characters.
let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_")
if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s }
// Wrap in single quotes; close/reopen around any embedded single quote.
return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Format a path for inclusion in a remote `sh -c` command. **Critical**
/// for any path containing `~/`: bash/zsh do NOT expand `~` inside
/// quotes (single OR double), so a single-quoted `'~/.hermes/foo'` is
/// passed to commands as the literal seven-character string
/// `~/.hermes/foo` and lookups fail. We rewrite the leading `~/` to
/// `$HOME/` (which DOES expand inside double quotes) and emit the path
/// double-quoted so embedded spaces / metacharacters are still safe.
///
/// Why not single-quote: that would make `$HOME` literal too. We
/// specifically need partial-expansion semantics, which is what double
/// quotes give us.
nonisolated private static func remotePathArg(_ path: String) -> String {
var p = path
if p.hasPrefix("~/") {
p = "$HOME/" + p.dropFirst(2)
} else if p == "~" {
p = "$HOME"
}
let escaped = p
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Run a remote shell command. Wraps in `sh -c '<command>'` and uses
/// the standard ssh-after-host placement (no `--` separator that
/// would be sent to the remote shell as a literal first token, which
/// most shells reject as "command not found"). The `command` is
/// single-quoted via `shellQuote` so ssh's argv-join-by-space doesn't
/// split it across multiple shell tokens on the remote side.
@discardableResult
nonisolated private func runRemoteShell(_ command: String, timeout: TimeInterval? = 60) throws -> ProcessResult {
var args = sshArgs()
args.append(hostSpec)
args.append("sh")
args.append("-c")
args.append(Self.shellQuote(command))
return try runLocal(executable: sshBinary, args: args, stdin: nil, timeout: timeout)
}
// MARK: - Files
func readFile(_ path: String) throws -> Data {
// `cat` is the simplest portable "give me file bytes" command; we
// don't need scp's progress machinery for typical config/memory
// files (<1 MB each).
let result = try runRemoteShell("cat \(Self.remotePathArg(path))")
if result.exitCode != 0 {
let errText = result.stderrString
// Missing file looks like exit 1 + "No such file" surface as a
// typed fileIO error so callers that treat missing == "empty"
// behave the same as they do locally.
if errText.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: errText)
}
return result.stdout
}
func writeFile(_ path: String, data: Data) throws {
// Atomic pattern:
// 1. scp to `<path>.scarf.tmp` on the remote
// 2. ssh `mv <tmp> <path>` atomic on POSIX within the same FS
// Hermes never sees a partial write.
let tmp = path + ".scarf.tmp"
// scp from a local temp file (scp reads from disk, not stdin).
let localTmpURL = FileManager.default.temporaryDirectory.appendingPathComponent(
"scarf-scp-\(UUID().uuidString).tmp"
)
do {
try data.write(to: localTmpURL)
} catch {
throw TransportError.fileIO(path: path, underlying: "local temp write: \(error.localizedDescription)")
}
defer { try? FileManager.default.removeItem(at: localTmpURL) }
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append(localTmpURL.path)
scpArgs.append("\(hostSpec):\(tmp)")
let scpResult = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 60)
if scpResult.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: scpResult.exitCode, stderr: scpResult.stderrString)
}
// Now atomic mv on the remote. Note: scp/sftp DOES expand `~` (it
// goes through the SSH file transfer protocol, not a remote shell),
// so the upload landed at the resolved $HOME path. The mv is a
// shell command and needs the $HOME-rewritten path to find it.
let mvResult = try runRemoteShell("mv \(Self.remotePathArg(tmp)) \(Self.remotePathArg(path))")
if mvResult.exitCode != 0 {
// Best-effort cleanup of the orphan tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(tmp))")
throw TransportError.classifySSHFailure(host: config.host, exitCode: mvResult.exitCode, stderr: mvResult.stderrString)
}
}
func fileExists(_ path: String) -> Bool {
guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else {
return false
}
return result.exitCode == 0
}
func stat(_ path: String) -> FileStat? {
// macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD
// form; `stat -c` is GNU/Linux. We try the GNU form first (typical
// remote target) and fall back to BSD. The format strings use
// double quotes safe inside our outer single-quoted sh -c.
let linux = try? runRemoteShell(#"stat -c "%s %Y %F" \#(Self.remotePathArg(path))"#)
if let result = linux, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
let bsd = try? runRemoteShell(#"stat -f "%z %m %HT" \#(Self.remotePathArg(path))"#)
if let result = bsd, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
return nil
}
private static func parseStatOutput(_ s: String) -> FileStat? {
// Expected: "<bytes> <unix-epoch-secs> <type>" where <type> is either
// a GNU word ("regular file", "directory") or a BSD word ("Regular
// File", "Directory"). Only the first word of <type> matters for
// isDirectory.
let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ", maxSplits: 2)
guard parts.count >= 2 else { return nil }
let size = Int64(parts[0]) ?? 0
let mtimeSecs = TimeInterval(parts[1]) ?? 0
let typeStr = parts.count == 3 ? parts[2].lowercased() : ""
let isDir = typeStr.contains("directory")
return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir)
}
func listDirectory(_ path: String) throws -> [String] {
// `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per
// line. Sort order matches local FileManager.contentsOfDirectory.
let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))")
if result.exitCode != 0 {
if result.stderrString.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
return result.stdoutString
.split(separator: "\n", omittingEmptySubsequences: true)
.map(String.init)
}
func createDirectory(_ path: String) throws {
let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
func removeFile(_ path: String) throws {
let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
// MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
// Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so
// home-relative args expand on the remote. The executable might be
// `~/.local/bin/hermes` or just `hermes`; either survives.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
}
func makeProcess(executable: String, args: [String]) -> Process {
ensureControlDir()
// `-T` disables pty allocation critical for binary-clean stdin/stdout
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
// so home-relative paths in `executable`/`args` actually expand.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
proc.arguments = sshArgv
proc.environment = Self.sshSubprocessEnvironment()
return proc
}
/// Environment for an ssh/scp subprocess: process env merged with
/// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell.
/// Without this, GUI-launched Scarf can't reach 1Password / Secretive /
/// `ssh-add`'d keys that the user's terminal sees fine.
nonisolated private static func sshSubprocessEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
env[key] = value
}
}
return env
}
// MARK: - SQLite snapshot
func snapshotSQLite(remotePath: String) throws -> URL {
try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true)
let localPath = snapshotDir + "/state.db"
// `.backup` is WAL-safe: sqlite takes a consistent snapshot without
// blocking writers. A plain `cp` of a WAL-mode DB could corrupt.
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
// sqlite3's `.backup` is a dot-command, not a CLI arg. The whole
// dot-command must be one shell argument (double-quoted) so sqlite3
// receives it as a single command; the backup path inside it is
// single-quoted so sqlite3 parses it correctly. The DB path is a
// separate shell argument and goes through `remotePathArg`
// (double-quoted, $HOME-aware) so `~/.hermes/state.db` actually
// resolves on the remote.
//
// The second sqlite3 invocation flips the snapshot out of WAL mode
// so the scp'd file is self-contained: `.backup` preserves the
// source's journal_mode in the destination header, so without this
// step the client would need the `-wal`/`-shm` sidecars too, and
// every read would fail with "unable to open database file".
//
// Final shell command on the remote:
// sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" \
// && sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;"
let backupScript = #"sqlite3 \#(Self.remotePathArg(remotePath)) ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
let backup = try runRemoteShell(backupScript)
if backup.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: backup.exitCode, stderr: backup.stderrString)
}
// scp the backup down. scp/sftp expands `~` natively (it goes
// through the SSH file-transfer protocol, not a remote shell), so
// remoteTmp's `/tmp/...` absolute path round-trips as-is.
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append("\(hostSpec):\(remoteTmp)")
scpArgs.append(localPath)
let pull = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 120)
// Regardless of pull outcome, try to clean up the remote tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(remoteTmp))")
if pull.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: pull.exitCode, stderr: pull.stderrString)
}
return URL(fileURLWithPath: localPath)
}
// MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
// Polling: call `stat -c %Y` on all paths every 3s and yield a single
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
// makes each stat ~5ms so the cost is bounded.
AsyncStream { continuation in
let task = Task.detached { [self] in
var lastSignature: String = ""
while !Task.isCancelled {
// Build one shell command that stats all paths in one
// ssh round-trip. Missing paths print "0" which still
// participates correctly in change detection. Paths
// get the `~``$HOME` rewrite via remotePathArg.
let argList = paths.map { Self.remotePathArg($0) }.joined(separator: " ")
let cmd = "for p in \(argList); do stat -c %Y \"$p\" 2>/dev/null || stat -f %m \"$p\" 2>/dev/null || echo 0; done"
do {
let result = try runRemoteShell(cmd, timeout: 30)
let signature = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
if !signature.isEmpty && signature != lastSignature {
if !lastSignature.isEmpty {
continuation.yield(.anyChanged)
}
lastSignature = signature
}
} catch {
// Transient failure (connection drop) skip this tick.
Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Private helpers
/// Spawn a local process (ssh/scp/etc.) and collect its result. Mirrors
/// `LocalTransport.runProcess` duplicated rather than shared because
/// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
ensureControlDir()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
// Inherit the user's shell environment so ssh can reach the
// ssh-agent socket. GUI-launched apps don't see SSH_AUTH_SOCK by
// default without this, terminal ssh works (because the user's
// shell exports it) but Scarf-launched ssh fails auth with exit 255.
proc.environment = Self.sshSubprocessEnvironment()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
}
@@ -0,0 +1,102 @@
import Foundation
/// Unified I/O surface shared by local and remote Hermes installations.
///
/// **Design rationale.** The services that read Hermes state (`~/.hermes/`)
/// and spawn the `hermes` CLI all boil down to a handful of primitives:
/// read/write/list files, stat file attributes, run a process to completion,
/// spawn a long-running stdio process for streaming, take a consistent DB
/// snapshot, observe file changes. `ServerTransport` exposes exactly those
/// primitives so the same service code works against either a local
/// filesystem or a remote host reached over SSH.
///
/// The primitives are deliberately **synchronous where possible** (file I/O,
/// process `run` + wait) so services don't need to become `async` end-to-end.
/// The two naturally-streaming cases log tail and ACP stdio use
/// `makeProcess` which returns a configured `Process`; services own the
/// stdio pipes and lifecycle exactly as they do today.
protocol ServerTransport: Sendable {
/// Identifies the context this transport serves. Used for cache
/// namespacing (e.g. per-server SQLite snapshot directories).
nonisolated var contextID: ServerID { get }
/// `true` if this transport talks to a remote host over SSH.
nonisolated var isRemote: Bool { get }
// MARK: - Files
nonisolated func readFile(_ path: String) throws -> Data
/// Atomic write: the file at `path` is either the previous contents or
/// the new contents, never a partial write. Preserves `0600` mode for
/// paths that match `.env` conventions so secrets stay owner-only.
nonisolated func writeFile(_ path: String, data: Data) throws
nonisolated func fileExists(_ path: String) -> Bool
nonisolated func stat(_ path: String) -> FileStat?
nonisolated func listDirectory(_ path: String) throws -> [String]
/// Create directories including intermediates. No-op if already present.
nonisolated func createDirectory(_ path: String) throws
/// Delete a file. No-op if absent.
nonisolated func removeFile(_ path: String) throws
// MARK: - Processes
/// Run a process to completion and capture its stdout/stderr. For remote
/// transports this actually invokes `ssh host -- executable args` under
/// the hood; for local it spawns `executable` directly.
nonisolated func runProcess(
executable: String,
args: [String],
stdin: Data?,
timeout: TimeInterval?
) throws -> ProcessResult
/// Return a `Process` configured for the target already pointed at the
/// right executable with the right arguments, but **not yet started**.
/// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient
/// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail.
///
/// Local: `executable` + `args` verbatim.
/// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args]`.
nonisolated func makeProcess(executable: String, args: [String]) -> Process
// MARK: - SQLite
/// Return a local filesystem URL pointing at a fresh, consistent copy of
/// the SQLite database at `remotePath`. For local transports this is
/// just the remote path unchanged. For SSH transports this performs
/// `sqlite3 .backup` on the remote side and scp's the backup into
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
// MARK: - Watching
/// Observe changes to a set of paths and yield events when any of them
/// change. Local: FSEvents. Remote: polls `stat` mtime every 3s.
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
}
/// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried.
struct FileStat: Sendable, Hashable {
let size: Int64
let mtime: Date
let isDirectory: Bool
}
/// Result of a one-shot process invocation.
struct ProcessResult: Sendable {
let exitCode: Int32
let stdout: Data
let stderr: Data
nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" }
}
enum WatchEvent: Sendable {
/// Any path in the watched set changed; implementations may coalesce
/// rapid changes into one event. Consumers should treat this as "refresh
/// whatever you were displaying" rather than expecting fine-grained
/// per-path signals.
case anyChanged
}
@@ -0,0 +1,86 @@
import Foundation
/// Typed errors surfaced by `ServerTransport` implementations. The UI
/// distinguishes these so user-visible messages can be specific
/// ("authentication failed" vs. "command failed") without having to grep
/// stderr strings.
enum TransportError: LocalizedError {
/// `ssh`/`scp` could not reach the host or hit a protocol-level issue
/// (name resolution, connection refused, route error).
case hostUnreachable(host: String, stderr: String)
/// Remote rejected our credentials. Typically means no ssh-agent key is
/// loaded, or the loaded keys don't match any `authorized_keys` entry.
case authenticationFailed(host: String, stderr: String)
/// Remote `~/.ssh/known_hosts` fingerprint no longer matches. Blocking
/// we never auto-accept on mismatch.
case hostKeyMismatch(host: String, stderr: String)
/// The command ran on the remote but exited non-zero.
case commandFailed(exitCode: Int32, stderr: String)
/// Local filesystem operation failed (read/write/stat) with the OS error
/// message attached.
case fileIO(path: String, underlying: String)
/// Timed out waiting for a process to finish. `partialStdout` carries
/// whatever output was captured before the timer fired.
case timeout(seconds: TimeInterval, partialStdout: Data)
/// Something we didn't plan for. Fall-through bucket with enough context
/// for a bug report.
case other(message: String)
var errorDescription: String? {
switch self {
case .hostUnreachable(let host, _):
return "Can't reach \(host). Check the hostname, network, and SSH config."
case .authenticationFailed(let host, _):
return "SSH authentication to \(host) failed. Ensure your key is loaded in ssh-agent."
case .hostKeyMismatch(let host, _):
return "Host key for \(host) has changed. Inspect ~/.ssh/known_hosts before continuing."
case .commandFailed(let code, let stderr):
// Trim stderr to a single line for the summary; full text is in
// the associated value for disclosure views.
let firstLine = stderr.split(separator: "\n").first.map(String.init) ?? ""
return "Remote command exited \(code). \(firstLine)"
case .fileIO(let path, let msg):
return "File I/O failed at \(path): \(msg)"
case .timeout(let secs, _):
return "Command timed out after \(Int(secs))s."
case .other(let msg):
return msg
}
}
/// Full stderr (if any) for display in a disclosure view. Empty string
/// when there's no additional detail worth showing.
var diagnosticStderr: String {
switch self {
case .hostUnreachable(_, let s),
.authenticationFailed(_, let s),
.hostKeyMismatch(_, let s),
.commandFailed(_, let s):
return s
default:
return ""
}
}
/// Heuristic classifier: convert the ssh/scp stderr of a failed command
/// into a specific `TransportError`. Used by `SSHTransport` after a
/// non-zero exit. Defaults to `.commandFailed` when no known marker
/// matches.
static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError {
let s = stderr.lowercased()
if s.contains("permission denied") || s.contains("authentication failed")
|| s.contains("publickey") && s.contains("denied") {
return .authenticationFailed(host: host, stderr: stderr)
}
if s.contains("host key verification failed")
|| s.contains("remote host identification has changed") {
return .hostKeyMismatch(host: host, stderr: stderr)
}
if s.contains("no route to host") || s.contains("connection refused")
|| s.contains("connection timed out") || s.contains("could not resolve hostname")
|| s.contains("connection closed by") && s.contains("port 22") {
return .hostUnreachable(host: host, stderr: stderr)
}
return .commandFailed(exitCode: exitCode, stderr: stderr)
}
}
@@ -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)
}
}
@@ -2,12 +2,20 @@ import Foundation
@Observable @Observable
final class ActivityViewModel { final class ActivityViewModel {
private let dataService = HermesDataService() let context: ServerContext
private let dataService: HermesDataService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
}
var toolMessages: [HermesMessage] = [] var toolMessages: [HermesMessage] = []
var filterKind: ToolKind? var filterKind: ToolKind?
var filterSessionId: String? var filterSessionId: String?
var selectedEntry: ActivityEntry? var selectedEntry: ActivityEntry?
var toolResult: String?
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var isLoading = true var isLoading = true
@@ -44,7 +52,12 @@ final class ActivityViewModel {
func load() async { func load() async {
isLoading = true isLoading = true
let opened = await dataService.open() // refresh() = close + reopen, which forces a fresh snapshot pull on
// remote contexts. Using open() here would short-circuit after the
// first load and show stale data for the view's lifetime. The DB
// stays open after load() returns so selectEntry() can read tool
// results without re-opening cleanup() closes on disappear.
let opened = await dataService.refresh()
guard opened else { guard opened else {
isLoading = false isLoading = false
return return
@@ -54,6 +67,15 @@ final class ActivityViewModel {
isLoading = false 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 { func cleanup() async {
await dataService.close() await dataService.close()
} }
@@ -1,8 +1,14 @@
import SwiftUI import SwiftUI
struct ActivityView: View { struct ActivityView: View {
@State private var viewModel = ActivityViewModel() @State private var viewModel: ActivityViewModel
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: ActivityViewModel(context: context))
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -17,6 +23,9 @@ struct ActivityView: View {
} }
.navigationTitle("Activity") .navigationTitle("Activity")
.task { await viewModel.load() } .task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
.onDisappear { Task { await viewModel.cleanup() } } .onDisappear { Task { await viewModel.cleanup() } }
} }
@@ -57,11 +66,8 @@ struct ActivityView: View {
List(selection: Binding( List(selection: Binding(
get: { viewModel.selectedEntry?.id }, get: { viewModel.selectedEntry?.id },
set: { id in set: { id in
if let id { let entry = id.flatMap { id in viewModel.filteredActivity.first(where: { $0.id == id }) }
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id }) Task { await viewModel.selectEntry(entry) }
} else {
viewModel.selectedEntry = nil
}
} }
)) { )) {
ForEach(viewModel.filteredActivity) { entry in ForEach(viewModel.filteredActivity) { entry in
@@ -146,14 +152,32 @@ struct ActivityView: View {
.clipShape(RoundedRectangle(cornerRadius: 6)) .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 { if !entry.messageContent.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Assistant Message") Text("Assistant Message")
.font(.caption.bold()) .font(.caption.bold())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(entry.messageContent) MarkdownContentView(content: entry.messageContent)
.font(.caption)
.textSelection(.enabled)
.padding(8) .padding(8)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5)) .background(.quaternary.opacity(0.5))
@@ -1,11 +1,34 @@
import Foundation import Foundation
import AppKit import AppKit
import SwiftTerm import SwiftTerm
import os
@Observable @Observable
final class ChatViewModel { final class ChatViewModel {
private let dataService = HermesDataService() private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
private let fileService = HermesFileService() let context: ServerContext
private let dataService: HermesDataService
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
self.fileService = HermesFileService(context: context)
self.richChatViewModel = RichChatViewModel(context: context)
// Probe hermes binary existence once off-main, then cache. Doing
// this synchronously inside `hermesBinaryExists`'s getter would
// block main on every chat-body re-evaluation for a remote
// context that's a SSH `test -e` round-trip on every streaming
// chunk, which manifests as the chat screen flashing or going
// blank during prompts.
Task.detached(priority: .userInitiated) { [context] in
let exists = context.fileExists(context.paths.hermesBinary)
await MainActor.run { [weak self] in
self?.hermesBinaryExists = exists
}
}
}
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
@@ -14,32 +37,466 @@ final class ChatViewModel {
var voiceEnabled = false var voiceEnabled = false
var ttsEnabled = false var ttsEnabled = false
var isRecording = false var isRecording = false
var displayMode: ChatDisplayMode = .richChat
let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator? private var coordinator: Coordinator?
var hermesBinaryExists: Bool { // ACP state
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) private var acpClient: ACPClient?
private var acpEventTask: Task<Void, Never>?
private var acpPromptTask: Task<Void, Never>?
private var healthMonitorTask: Task<Void, Never>?
private var reconnectTask: Task<Void, Never>?
private var isHandlingDisconnect = false
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = ""
var acpError: String?
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
/// Shown above the raw error in the UI when present.
var acpErrorHint: String?
/// Tail of stderr captured from `hermes acp` at the time of the last
/// failure shown in a collapsible details section so users can copy/paste.
var acpErrorDetails: String?
/// True when `hasAnyAICredential()` returned false at last preflight.
var missingCredentials: Bool = false
private static let maxReconnectAttempts = 5
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
/// Cached result of probing for `hermes` on the target server. Updated
/// once at init by a detached task; defaults to `true` so the chat
/// view doesn't briefly flash "Hermes not found" while the async
/// probe runs. Set to `false` only after the probe confirms the
/// binary really isn't there.
var hermesBinaryExists: Bool = true
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
/// updates `missingCredentials`. Cheap safe to call from view `.task`.
func refreshCredentialPreflight() {
missingCredentials = !fileService.hasAnyAICredential()
} }
/// Clears the error/hint/details triplet so future failures overwrite
/// cleanly instead of stacking on top of stale state.
private func clearACPErrorState() {
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
}
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
/// stderr tail the ACP client captured, and logs the failure with a
/// site-specific context label. Call on any failure path.
@MainActor
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
let msg = error.localizedDescription
logger.error("\(context): \(msg)")
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
// MARK: - Session Lifecycle
func startNewSession() { func startNewSession() {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil)
} else {
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
} }
}
func resumeSession(_ sessionId: String) { func resumeSession(_ sessionId: String) {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: sessionId)
} else {
richChatViewModel.setSessionId(sessionId)
launchTerminal(arguments: ["chat", "--resume", sessionId]) launchTerminal(arguments: ["chat", "--resume", sessionId])
} }
}
func continueLastSession() { func continueLastSession() {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
// Find most recent session and resume via ACP
Task { @MainActor in
let opened = await dataService.open()
if !opened {
acpError = context.isRemote
? "Couldn't reach \(context.displayName). Check the SSH connection and try again."
: "Couldn't open the Hermes state database."
acpErrorHint = nil
acpErrorDetails = nil
return
}
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
await dataService.close()
if let sessionId {
startACPSession(resume: sessionId)
} else {
startACPSession(resume: nil)
}
}
} else {
launchTerminal(arguments: ["chat", "--continue"]) 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 session (or create a new one), then send the
/// queued prompt. Typing into a blank Chat screen ALWAYS creates a new
/// session the "Continue from Last Session" button is the explicit path
/// for resuming. The previous behavior (falling back to the most recently
/// active session in the DB) would pick up cron/background sessions the
/// user never interacted with; those can be garbage-collected by Hermes
/// between the DB read and ACP `session/load`, producing a silent prompt
/// failure with no UI feedback.
private func autoStartACPAndSend(text: String) {
// Show the user message immediately
richChatViewModel.addUserMessage(text: text)
Task { @MainActor in
let sessionToResume = richChatViewModel.sessionId
let client = ACPClient(context: context)
self.acpClient = client
do {
try await client.start()
acpStatus = await client.statusMessage
startACPEventLoop(client: client)
startHealthMonitor(client: client)
let cwd = await context.resolvedUserHome()
hasActiveProcess = true
let resolvedSessionId: String
if let existing = sessionToResume {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
} catch {
logger.info("Session \(existing) not found in ACP, creating new session")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Now send the queued prompt
sendViaACP(client: client, text: text)
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
hasActiveProcess = false
acpClient = nil
}
}
}
private func sendViaACP(client: ACPClient, text: String) {
guard let sessionId = richChatViewModel.sessionId else {
clearACPErrorState()
acpError = "No session ID — cannot send"
return
}
// Don't duplicate user message if autoStartACPAndSend already added it
if richChatViewModel.messages.last?.isUser != true
|| richChatViewModel.messages.last?.content != text {
richChatViewModel.addUserMessage(text: text)
}
acpStatus = "Agent working..."
acpPromptTask = Task { @MainActor in
do {
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
acpStatus = "Ready"
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
acpStatus = "Error"
await recordACPFailure(error, client: client, context: "ACP prompt failed")
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
stopReason: "error",
inputTokens: 0, outputTokens: 0,
thoughtTokens: 0, cachedReadTokens: 0
))
)
}
}
}
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?) {
stopACP()
clearACPErrorState()
acpStatus = "Starting..."
let client = ACPClient(context: context)
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 = await context.resolvedUserHome()
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
// and doesn't wipe messages with a DB refresh
hasActiveProcess = true
let resolvedSessionId: String
if let sessionId {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
// Load messages from both origin CLI session and ACP session
await richChatViewModel.loadSessionHistory(
sessionId: sessionId,
acpSessionId: resolvedSessionId
)
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions()
logger.info("ACP session ready: \(resolvedSessionId)")
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
hasActiveProcess = false
acpClient = nil
}
}
}
private func startACPEventLoop(client: ACPClient) {
acpEventTask = Task { @MainActor [weak self] in
let eventStream = await client.events
for await event in eventStream {
guard !Task.isCancelled else { break }
self?.richChatViewModel.handleACPEvent(event)
self?.acpStatus = await client.statusMessage
}
// Stream ended if we weren't cancelled, the connection died
if !Task.isCancelled {
self?.handleConnectionDied()
}
}
}
private func startHealthMonitor(client: ACPClient) {
healthMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { break }
let healthy = await client.isHealthy
if !healthy {
self?.handleConnectionDied()
break
}
}
}
}
private func handleConnectionDied() {
guard acpClient != nil, !isHandlingDisconnect else { return }
isHandlingDisconnect = true
logger.warning("ACP connection died")
// Finalize any in-progress streaming message before reconnection
richChatViewModel.finalizeOnDisconnect()
// Save session ID for reconnection before cleaning up
let savedSessionId = richChatViewModel.sessionId
// Clean up the dead client
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
// Attempt auto-reconnect if we have a session to restore
guard let savedSessionId else {
showConnectionFailure()
isHandlingDisconnect = false
return
}
attemptReconnect(sessionId: savedSessionId)
}
private func attemptReconnect(sessionId: String) {
reconnectTask?.cancel()
clearACPErrorState()
reconnectTask = Task { @MainActor [weak self] in
guard let self else { return }
for attempt in 1...Self.maxReconnectAttempts {
guard !Task.isCancelled else { return }
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
// Backoff delay (skip on first attempt for fast recovery)
if attempt > 1 {
let delay = min(
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
Self.maxReconnectDelay
)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
}
let client = ACPClient(context: context)
do {
try await client.start()
let cwd = await context.resolvedUserHome()
let resolvedSessionId: String
// Try resumeSession first (designed for reconnection), then loadSession.
// NEVER fall back to newSession that loses all conversation context.
do {
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
}
// Success wire up the new client
self.acpClient = client
self.hasActiveProcess = true
richChatViewModel.setSessionId(resolvedSessionId)
// Reconcile in-memory messages with what Hermes persisted to DB
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
clearACPErrorState()
startACPEventLoop(client: client)
startHealthMonitor(client: client)
isHandlingDisconnect = false
logger.info("Reconnected successfully on attempt \(attempt)")
return
} catch {
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
await client.stop()
continue
}
}
// All attempts exhausted
guard !Task.isCancelled else { return }
showConnectionFailure()
isHandlingDisconnect = false
}
}
private func showConnectionFailure() {
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
acpStatus = "Connection lost"
clearACPErrorState()
acpError = "Connection lost. Use the Session menu to reconnect."
}
func stopACP() {
reconnectTask?.cancel()
reconnectTask = nil
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
isHandlingDisconnect = false
}
/// Respond to a permission request from the ACP agent.
func respondToPermission(optionId: String) {
guard let client = acpClient,
let permission = richChatViewModel.pendingPermission else { return }
Task {
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
}
richChatViewModel.pendingPermission = nil
}
// MARK: - Recent Sessions
func loadRecentSessions() async { func loadRecentSessions() async {
let opened = await dataService.open() let opened = await dataService.open()
@@ -55,6 +512,8 @@ final class ChatViewModel {
return session.id return session.id
} }
// MARK: - Voice (terminal mode only)
func toggleVoice() { func toggleVoice() {
guard let tv = terminalView else { return } guard let tv = terminalView else { return }
if voiceEnabled { if voiceEnabled {
@@ -76,18 +535,21 @@ final class ChatViewModel {
func pushToTalk() { func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return } guard let tv = terminalView, voiceEnabled else { return }
// Ctrl+B = ASCII 0x02
let ctrlB: [UInt8] = [0x02] let ctrlB: [UInt8] = [0x02]
tv.send(source: tv, data: ctrlB[0..<1]) tv.send(source: tv, data: ctrlB[0..<1])
isRecording.toggle() isRecording.toggle()
} }
// MARK: - Terminal Mode
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) { private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8) let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count]) tv.send(source: tv, data: bytes[0..<bytes.count])
} }
private func launchTerminal(arguments: [String]) { private func launchTerminal(arguments: [String]) {
stopACP()
if let existing = terminalView { if let existing = terminalView {
existing.terminate() existing.terminate()
existing.removeFromSuperview() existing.removeFromSuperview()
@@ -102,6 +564,7 @@ final class ChatViewModel {
self?.hasActiveProcess = false self?.hasActiveProcess = false
self?.voiceEnabled = false self?.voiceEnabled = false
self?.isRecording = false self?.isRecording = false
Task { await self?.richChatViewModel.refreshMessages() }
}) })
terminal.processDelegate = coord terminal.processDelegate = coord
self.coordinator = coord self.coordinator = coord
@@ -109,11 +572,44 @@ final class ChatViewModel {
var env = ProcessInfo.processInfo.environment var env = ProcessInfo.processInfo.environment
env["TERM"] = "xterm-256color" env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor" env["COLORTERM"] = "truecolor"
// Inherit ssh-agent socket for remote so password-less auth works.
if context.isRemote {
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
}
let envArray = env.map { "\($0.key)=\($0.value)" } let envArray = env.map { "\($0.key)=\($0.value)" }
// For remote: wrap the invocation in `ssh -t host -- hermes <args>`
// so the embedded terminal opens a pty against the remote and the
// hermes TUI gets the bytes it expects. `-t` requests a pty (the
// SwiftTerm view is one).
let exe: String
let argv: [String]
if context.isRemote, case .ssh(let cfg) = context.kind {
let host = cfg.user.map { "\($0)@\(cfg.host)" } ?? cfg.host
exe = "/usr/bin/ssh"
var sshArgs: [String] = ["-t"]
if let port = cfg.port { sshArgs += ["-p", String(port)] }
if let id = cfg.identityFile, !id.isEmpty { sshArgs += ["-i", id] }
sshArgs += ["-o", "StrictHostKeyChecking=accept-new"]
sshArgs += ["-o", "BatchMode=yes"]
sshArgs.append(host)
sshArgs.append("--")
sshArgs.append(context.paths.hermesBinary)
sshArgs.append(contentsOf: arguments)
argv = sshArgs
} else {
exe = context.paths.hermesBinary
argv = arguments
}
terminal.startProcess( terminal.startProcess(
executable: HermesPaths.hermesBinary, executable: exe,
args: arguments, args: argv,
environment: envArray, environment: envArray,
execName: nil execName: nil
) )
@@ -0,0 +1,606 @@
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 {
let context: ServerContext
private let dataService: HermesDataService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
}
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) {
// Detect a failed prompt that produced no assistant output e.g.
// Hermes returning `stopReason: "refusal"` when the session was
// silently garbage-collected, or `"error"` when the ACP call itself
// threw. Without surfacing this, the user sees their prompt sitting
// alone under "Agent working" that never completes with any text.
let hadAssistantOutput = streamingAssistantText.isEmpty == false
|| messages.last?.isAssistant == true
finalizeStreamingMessage()
if !hadAssistantOutput, response.stopReason != "end_turn" {
let reason: String
switch response.stopReason {
case "refusal":
reason = "The agent refused to respond (the session may have been cleared on the server). Try starting a new session from the Session menu."
case "error":
reason = "The prompt failed — check the ACP error banner above for details."
case "max_tokens":
reason = "The response was cut off before the agent could produce any output (max_tokens reached before any tokens were emitted)."
default:
reason = "The prompt ended without a response (stopReason: \(response.stopReason))."
}
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "system",
content: reason,
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: response.stopReason,
reasoning: nil
))
}
// 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
// Force a fresh snapshot pull on remote contexts. An earlier open()
// would have cached a stale copy on resume we need whatever
// Hermes has actually persisted since then, or the resumed session
// will show only history up to the moment the snapshot was taken.
let opened = await dataService.refresh()
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 {
// Polling tick (terminal mode): pull a fresh snapshot so remote
// reflects Hermes writes since the last tick. On local this is a
// cheap reopen of the live DB.
let opened = await dataService.refresh()
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
}
}
+273 -6
View File
@@ -3,32 +3,154 @@ import SwiftUI
struct ChatView: View { struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel @Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
@State private var showErrorDetails = false
var body: some View { var body: some View {
@Bindable var vm = viewModel
VStack(spacing: 0) { VStack(spacing: 0) {
toolbar toolbar
Divider() Divider()
terminalArea errorBanner
chatArea
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() } .task {
await viewModel.loadRecentSessions()
viewModel.refreshCredentialPreflight()
}
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() } Task { await viewModel.loadRecentSessions() }
viewModel.refreshCredentialPreflight()
}
}
/// Banner rendered between the toolbar and the chat area when either
/// (a) a preflight credential check failed, or (b) the ACP subprocess
/// returned an error we captured. Shows a short hint + expandable raw
/// details (stderr tail) that the user can copy to the clipboard.
@ViewBuilder
private var errorBanner: some View {
if let err = viewModel.acpError {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
if let hint = viewModel.acpErrorHint {
Text(hint)
.font(.callout)
.textSelection(.enabled)
}
Text(err)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(showErrorDetails ? nil : 2)
}
Spacer()
if viewModel.acpErrorDetails != nil {
Button(showErrorDetails ? "Hide details" : "Show details") {
showErrorDetails.toggle()
}
.buttonStyle(.borderless)
.controlSize(.small)
}
Button {
let payload = [viewModel.acpErrorHint, err, viewModel.acpErrorDetails]
.compactMap { $0 }
.joined(separator: "\n\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(payload, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("Copy error details")
}
if showErrorDetails, let details = viewModel.acpErrorDetails {
ScrollView {
Text(details)
.font(.system(.caption2, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 160)
.padding(8)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(10)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.fill(Color.orange.opacity(0.25))
.frame(height: 1),
alignment: .bottom
)
} else if viewModel.missingCredentials && !viewModel.hasActiveProcess {
HStack(spacing: 8) {
Image(systemName: "key.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("No AI provider credentials detected")
.font(.callout)
Text("Add credentials in **Configure → Credential Pools**, set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env`, or export it in your shell profile, then restart Scarf.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(10)
.background(Color.orange.opacity(0.08))
.overlay(
Rectangle()
.fill(Color.orange.opacity(0.25))
.frame(height: 1),
alignment: .bottom
)
} }
} }
private var toolbar: some View { private var toolbar: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "terminal") Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if viewModel.hasActiveProcess { if viewModel.hasActiveProcess {
Circle() Circle()
.fill(.green) .fill(.green)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
Text("Active") Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .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 { } else {
Circle() Circle()
.fill(.secondary) .fill(.secondary)
@@ -40,10 +162,21 @@ struct ChatView: View {
Spacer() Spacer()
if viewModel.hasActiveProcess { if viewModel.hasActiveProcess && viewModel.displayMode == .terminal {
voiceControls 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 { if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle") Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
@@ -51,6 +184,12 @@ struct ChatView: View {
} }
Menu { Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") {
viewModel.richChatViewModel.requestScrollToBottom()
}
Divider()
}
Button("New Session") { Button("New Session") {
viewModel.startNewSession() viewModel.startNewSession()
} }
@@ -60,6 +199,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty { if !viewModel.recentSessions.isEmpty {
Divider() Divider()
Text("Resume Session") Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in ForEach(viewModel.recentSessions) { session in
Button { Button {
viewModel.resumeSession(session.id) viewModel.resumeSession(session.id)
@@ -75,6 +216,7 @@ struct ChatView: View {
} }
} }
} }
.disabled(session.id == activeSessionId || session.id == originSessionId)
} }
} }
} label: { } label: {
@@ -137,6 +279,16 @@ struct ChatView: View {
} }
} }
@ViewBuilder
private var chatArea: some View {
switch viewModel.displayMode {
case .terminal:
terminalArea
case .richChat:
richChatArea
}
}
@ViewBuilder @ViewBuilder
private var terminalArea: some View { private var terminalArea: some View {
if let terminal = viewModel.terminalView { if let terminal = viewModel.terminalView {
@@ -152,9 +304,124 @@ struct ChatView: View {
ContentUnavailableView( ContentUnavailableView(
"Hermes Not Found", "Hermes Not Found",
systemImage: "terminal", systemImage: "terminal",
description: Text("Expected at \(HermesPaths.hermesBinary)") description: Text("Expected at \(viewModel.context.paths.hermesBinary)")
) )
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 \(viewModel.context.paths.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: 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,165 @@
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()
/// Why `.defaultScrollAnchor(.bottom)` *alone* and no `proxy.scrollTo`.
///
/// `.defaultScrollAnchor(.bottom)` tells SwiftUI to pin the viewport to
/// the bottom of the content automatically as messages stream in or
/// new turns arrive, the scroll position tracks the bottom edge.
///
/// We used to also call `proxy.scrollTo(lastID, anchor: .bottom)` from
/// six different `onChange` handlers during streaming. The two
/// mechanisms fought each other: the ScrollViewReader can resolve an ID
/// to a position **before** LazyVStack has finished laying out that
/// row, so `scrollTo` would land past the actual content the
/// "viewport showing whitespace, chat is above" symptom. Removing the
/// manual scroll and trusting `defaultScrollAnchor` eliminates the race.
///
/// The only remaining explicit scroll is `scrollTrigger` for the "Return
/// to Active Session" button; that fires rarely, after layout has
/// settled, so the overshoot doesn't happen.
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if groups.isEmpty && !isWorking {
emptyState
}
ForEach(groups) { group in
MessageGroupView(group: group)
.id("group-\(group.id)")
}
if isWorking {
typingIndicator
.id("typing-indicator")
}
}
.padding()
}
.defaultScrollAnchor(.bottom)
.onChange(of: scrollTrigger) {
let target = lastAnchorID
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
}
/// Anchor ID used by the explicit scrollTrigger path. Prefers the typing
/// indicator when visible (so we scroll to the very bottom of the
/// current turn), otherwise the last group.
private var lastAnchorID: String {
if isWorking { return "typing-indicator" }
if let last = groups.last { return "group-\(last.id)" }
return "group-0"
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "bubble.left.and.text.bubble.right")
.font(.system(size: 40))
.foregroundStyle(.tertiary)
Text("Chat Messages")
.font(.title3)
.fontWeight(.semibold)
Text("Messages will appear here as the conversation progresses.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
}
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: [:])
}
// Identify by array offset rather than `message.id`. The
// streaming assistant message starts with id=0 and gets a
// new negative id when finalized using `\.id` would make
// SwiftUI think the bubble disappeared and a new one appeared
// (destroying + recreating the view, which manifests as the
// chat flashing or jumping right when the prompt completes).
// Within a single group the assistant messages are
// append-only, so offset is a stable identity for the
// group's lifetime.
let assistantMessages = group.assistantMessages.filter(\.isAssistant)
ForEach(Array(assistantMessages.enumerated()), id: \.offset) { _, 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,50 @@
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()
// Always mount RichChatMessageList; empty state lives inside it.
// Swapping between a ContentUnavailableView and the ScrollView
// hierarchy on first message caused a full view tree rebuild,
// which manifests as a white flash.
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
)
Divider()
RichChatInputBar(
onSend: { text in
onSend(text)
},
isEnabled: isEnabled,
supportsCompress: richChat.supportsCompress
)
}
// DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
richChat.scheduleRefresh()
}
}
}
}
@@ -0,0 +1,189 @@
import SwiftUI
struct RichMessageBubble: View {
let message: HermesMessage
let toolResults: [String: HermesMessage]
var body: some View {
if message.isUser {
userBubble
} else if message.isAssistant {
assistantBubble
}
// Tool result messages are rendered inline in ToolCallCard, not as standalone bubbles
}
// MARK: - User Bubble
private var userBubble: some View {
VStack(alignment: .trailing, spacing: 2) {
HStack {
Spacer(minLength: 80)
Text(message.content)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
if let time = message.timestamp {
Text(time, style: .time)
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.trailing, 4)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
// MARK: - Assistant Bubble
private var assistantBubble: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
VStack(alignment: .leading, spacing: 8) {
if message.hasReasoning {
reasoningSection
}
if !message.content.isEmpty {
contentView
}
if !message.toolCalls.isEmpty {
toolCallsSection
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 40)
}
metadataFooter
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Content Rendering
@ViewBuilder
private var contentView: some View {
let blocks = parseContentBlocks(message.content)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .text(let text):
MarkdownContentView(content: text)
case .code(let code, let language):
CodeBlockView(code: code, language: language)
}
}
}
}
// MARK: - Reasoning
private var reasoningSection: some View {
DisclosureGroup {
Text(message.reasoning ?? "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
} label: {
HStack(spacing: 4) {
Text("Reasoning")
if let tokens = message.tokenCount, tokens > 0 {
Text("(\(tokens) tokens)")
.foregroundStyle(.tertiary)
}
}
}
.font(.caption.bold())
.foregroundStyle(.orange)
}
// MARK: - Tool Calls
private var toolCallsSection: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(message.toolCalls) { call in
ToolCallCard(
call: call,
result: toolResults[call.callId]
)
}
}
}
// MARK: - Metadata Footer
private var metadataFooter: some View {
HStack(spacing: 8) {
if let tokens = message.tokenCount, tokens > 0 {
Text("\(tokens) tokens")
}
if let reason = message.finishReason, !reason.isEmpty {
Text(reason)
}
if let time = message.timestamp {
Text(time, style: .time)
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
}
// MARK: - Content Block Parsing
private enum ContentBlock {
case text(String)
case code(String, String?)
}
private func parseContentBlocks(_ content: String) -> [ContentBlock] {
var blocks: [ContentBlock] = []
let lines = content.components(separatedBy: "\n")
var currentText: [String] = []
var currentCode: [String] = []
var codeLanguage: String?
var inCode = false
for line in lines {
if !inCode && line.hasPrefix("```") {
if !currentText.isEmpty {
blocks.append(.text(currentText.joined(separator: "\n")))
currentText = []
}
inCode = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
} else if inCode && line.hasPrefix("```") {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
currentCode = []
codeLanguage = nil
inCode = false
} else if inCode {
currentCode.append(line)
} else {
currentText.append(line)
}
}
if inCode && !currentCode.isEmpty {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
}
if !currentText.isEmpty {
let text = currentText.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(.text(text))
}
}
return blocks
}
@@ -0,0 +1,85 @@
import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
let isWorking: Bool
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
var body: some View {
HStack(spacing: 16) {
if let session {
HStack(spacing: 4) {
Circle()
.fill(isWorking ? .green : .secondary)
.frame(width: 6, height: 6)
.opacity(isWorking ? 1 : 0.6)
if isWorking {
Text("Working")
.font(.caption)
.foregroundStyle(.green)
}
}
if let title = session.title, !title.isEmpty {
Text(title)
.font(.caption.bold())
.lineLimit(1)
.truncationMode(.tail)
}
if let model = session.model {
Label(model, systemImage: "cpu")
}
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
.contentTransition(.numericText())
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
.contentTransition(.numericText())
}
if let start = session.startedAt {
Label {
Text(start, style: .relative)
.monospacedDigit()
} icon: {
Image(systemName: "clock")
}
}
Spacer()
Label(session.source, systemImage: session.sourceIcon)
} else {
Text("No active session")
.foregroundStyle(.tertiary)
Spacer()
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.bar)
}
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
}
@@ -0,0 +1,134 @@
import SwiftUI
struct ToolCallCard: View {
let call: HermesToolCall
let result: HermesMessage?
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1)
.fill(toolColor)
.frame(width: 3, height: 16)
Image(systemName: call.toolKind.icon)
.font(.caption)
.foregroundStyle(toolColor)
Text(call.functionName)
.font(.caption.monospaced().bold())
.foregroundStyle(.primary)
Text(call.argumentsSummary)
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if result != nil {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
} else {
ProgressView()
.controlSize(.mini)
}
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.padding(.horizontal, 8)
if expanded {
VStack(alignment: .leading, spacing: 6) {
if !call.arguments.isEmpty && call.arguments != "{}" {
Text("Arguments")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
Text(formatJSON(call.arguments))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
if let result, !result.content.isEmpty {
Text("Result")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
ToolResultContent(content: result.content)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 6)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var toolColor: Color {
switch call.toolKind {
case .read: return .green
case .edit: return .blue
case .execute: return .orange
case .fetch: return .purple
case .browser: return .indigo
case .other: return .secondary
}
}
private func formatJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
}
struct ToolResultContent: View {
let content: String
@State private var showAll = false
private var lines: [String] { content.components(separatedBy: "\n") }
private var isLong: Bool { lines.count > 8 }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
if isLong {
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
withAnimation { showAll.toggle() }
}
.font(.caption2)
.foregroundStyle(Color.accentColor)
}
}
}
}
@@ -0,0 +1,74 @@
import SwiftUI
/// Translucent loading overlay used by feature views while their VM's
/// `load()` runs in the background. Shows a centered ProgressView with
/// optional label; the underlying content stays visible (just dimmed)
/// when it's already populated, or the overlay fully covers an empty
/// section so the user sees activity instead of "nothing here yet".
///
/// Usage:
/// ```swift
/// SomeContent()
/// .loadingOverlay(viewModel.isLoading, label: "Loading credentials", isEmpty: viewModel.pools.isEmpty)
/// ```
///
/// The `isEmpty` flag controls whether the overlay covers the full view
/// (when there's no stale content to show under it) or just dims it
/// (when refreshing existing data).
struct LoadingOverlay: ViewModifier {
let isLoading: Bool
let label: String
let isEmpty: Bool
func body(content: Content) -> some View {
content
.overlay {
if isLoading {
if isEmpty {
// Full cover: empty state. User has no data to look at,
// so own the whole pane with the spinner.
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text(label)
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.windowBackgroundColor))
} else {
// Stale-content refresh: top-trailing pill so the
// user sees data is being refreshed without losing
// their place.
VStack {
HStack {
Spacer()
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.thinMaterial, in: Capsule())
.padding(8)
}
Spacer()
}
}
}
}
}
}
extension View {
/// Show a loading indicator while `isLoading` is true. If `isEmpty` is
/// also true, the indicator covers the full view; otherwise it shows
/// as a small refresh pill in the top-trailing corner so existing
/// content stays visible.
func loadingOverlay(_ isLoading: Bool, label: String = "Loading…", isEmpty: Bool = false) -> some View {
modifier(LoadingOverlay(isLoading: isLoading, label: label, isEmpty: isEmpty))
}
}
@@ -0,0 +1,268 @@
import Foundation
import AppKit
import os
/// A single pooled credential for a provider (rotation entry).
struct HermesCredential: Identifiable, Sendable, Equatable {
var id: String { "\(provider):\(index):\(internalID)" }
let internalID: String // Stable id from auth.json (e.g. "9f8d9b")
let provider: String
let index: Int // 0-based index in the provider's pool
let label: String // Human label ("OPENROUTER_API_KEY")
let authType: String // "api_key" | "oauth"
let source: String // "env:OPENROUTER_API_KEY" | "gh_cli" | "file:..."
let tokenTail: String // Last 4 chars of the token NEVER store full token in UI state
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
let requestCount: Int
}
/// Summary of one provider's pool with its rotation strategy.
struct HermesCredentialPool: Identifiable, Sendable {
var id: String { provider }
let provider: String
let strategy: String // "fill_first" | "round_robin" | "least_used" | "random"
let credentials: [HermesCredential]
}
@Observable
@MainActor
final class CredentialPoolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "CredentialPoolsViewModel")
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
self.oauthFlow = OAuthFlowController(context: context)
}
var pools: [HermesCredentialPool] = []
var isLoading = false
var message: String?
/// Driver for the OAuth flow. Uses Process + pipes (not SwiftTerm) so we
/// can extract the authorization URL, pop it open with an explicit button,
/// and feed the code back via stdin. See OAuthFlowController for why we
/// moved off the embedded-terminal approach.
let oauthFlow: OAuthFlowController
var oauthProvider: String = ""
/// Convenience the sheet keys a lot of UI off "is the flow running?".
var oauthInProgress: Bool { oauthFlow.isRunning }
let strategyOptions = ["fill_first", "round_robin", "least_used", "random"]
/// Source of truth is `~/.hermes/auth.json`. Parsing box-drawn `hermes auth list`
/// output is fragile the JSON file is structured, stable, and already stores
/// exactly the pool data the UI needs. We never display full tokens.
///
/// Runs the file reads on a detached task so the synchronous SSH calls
/// (which can block for hundreds of milliseconds even with ControlMaster
/// multiplexing) don't freeze the main thread / spin the beach ball.
func load() {
isLoading = true
let ctx = context
Task.detached { [weak self] in
let authData = ctx.readData(ctx.paths.authJSON)
let yaml = ctx.readText(ctx.paths.configYAML) ?? ""
let strategies = Self.parseStrategies(from: yaml)
let decodedPools: [HermesCredentialPool]
if let data = authData,
let decoded = try? JSONDecoder().decode(AuthFile.self, from: data) {
decodedPools = Self.buildPools(from: decoded, strategies: strategies)
} else {
decodedPools = []
}
await MainActor.run { [weak self] in
self?.pools = decodedPools
self?.isLoading = false
}
}
}
/// The `credential_pool_strategies:` map lives in config.yaml as `<provider>: <strategy>`.
/// Pure-function form so it's safe to call from the detached load task.
nonisolated private static func parseStrategies(from yaml: String) -> [String: String] {
guard !yaml.isEmpty else { return [:] }
let parsed = HermesFileService.parseNestedYAML(yaml)
return parsed.maps["credential_pool_strategies"] ?? [:]
}
nonisolated private static func buildPools(from auth: AuthFile, strategies: [String: String]) -> [HermesCredentialPool] {
auth.credential_pool.keys.sorted().map { provider in
let entries = auth.credential_pool[provider] ?? []
let creds = entries.enumerated().map { index, entry in
HermesCredential(
internalID: entry.id ?? "",
provider: provider,
index: index,
label: entry.label ?? entry.source ?? "",
authType: entry.auth_type ?? "",
source: entry.source ?? "",
tokenTail: Self.tail(of: entry.access_token ?? ""),
lastStatus: entry.last_status ?? "",
requestCount: entry.request_count ?? 0
)
}
return HermesCredentialPool(
provider: provider,
strategy: strategies[provider] ?? "fill_first",
credentials: creds
)
}
}
/// Return last 4 chars prefixed with "", or "" if the token is too short.
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
nonisolated private static func tail(of token: String) -> String {
guard token.count >= 4 else { return "" }
return "" + String(token.suffix(4))
}
// MARK: - Mutations (all routed through the hermes CLI so hermes stays authoritative)
func setStrategy(_ strategy: String, for provider: String) {
let result = runHermes(["config", "set", "credential_pool_strategies.\(provider)", strategy])
if result.exitCode == 0 {
message = "Strategy updated for \(provider)"
load()
} else {
message = "Failed to update strategy"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
/// Add an API-key credential to a provider's pool. Runs non-interactively.
///
/// **Critical:** we must pass `--type api-key` in addition to `--api-key`.
/// Without `--type`, hermes falls back to the provider's default (OAuth for
/// Anthropic, etc.) and launches the browser flow even though the user
/// just gave us a key.
func addAPIKey(provider: String, apiKey: String, label: String) {
var args = ["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
if !trimmedLabel.isEmpty {
args += ["--label", trimmedLabel]
}
let result = runHermes(args)
if result.exitCode == 0 {
message = "Credential added"
load()
} else {
logger.warning("Add credential failed: \(result.output)")
message = "Add failed: \(result.output.prefix(160))"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
/// Kick off the OAuth flow. Uses OAuthFlowController (Process + pipes) so
/// we can detect the authorization URL from hermes's output, open the
/// browser ourselves, and feed the code back via stdin avoiding the
/// subprocess-can't-open-browser problem SwiftTerm had.
func startOAuth(provider: String, label: String) {
guard !provider.isEmpty else { return }
oauthProvider = provider
oauthFlow.onExit = { [weak self] _ in
guard let self else { return }
self.message = self.oauthFlow.succeeded
? "OAuth login succeeded"
: (self.oauthFlow.errorMessage ?? "OAuth login failed or cancelled")
// Reload regardless hermes may have written a partial credential
// even on a soft failure, and we want the list to reflect truth.
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.message = nil
}
}
oauthFlow.start(provider: provider, label: label)
}
/// Submit the authorization code the user pasted into the form's text
/// field. Writes it to hermes's stdin.
func submitOAuthCode(_ code: String) {
oauthFlow.submitCode(code)
}
/// Cancel an in-progress OAuth attempt (e.g., user closed the sheet).
func cancelOAuth() {
oauthFlow.stop()
}
func removeCredential(provider: String, index: Int) {
// The CLI uses 1-based indexing ("#1", "#2" in `hermes auth list`); our
// stored `index` is 0-based, so add 1 when handing to the CLI.
let result = runHermes(["auth", "remove", provider, String(index + 1)])
if result.exitCode == 0 {
message = "Credential removed"
load()
} else {
message = "Remove failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func resetProvider(_ provider: String) {
let result = runHermes(["auth", "reset", provider])
message = result.exitCode == 0 ? "Cooldowns cleared for \(provider)" : "Reset failed"
load()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
context.runHermes(arguments)
}
}
// MARK: - auth.json decoding
// Shape verified against a real `~/.hermes/auth.json` see sample in plan notes.
// All fields are optional because the format evolves and we want decoding to
// succeed even if hermes adds new keys or omits some for certain auth types.
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a MainActor-
// isolated conformance auth.json decode runs in `load()`'s detached task.
private struct AuthFile: Decodable, Sendable {
nonisolated let credential_pool: [String: [AuthEntry]]
enum CodingKeys: String, CodingKey { case credential_pool }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.credential_pool = try c.decode([String: [AuthEntry]].self, forKey: .credential_pool)
}
}
private struct AuthEntry: Decodable, Sendable {
nonisolated let id: String?
nonisolated let label: String?
nonisolated let auth_type: String?
nonisolated let source: String?
nonisolated let access_token: String?
nonisolated let last_status: String?
nonisolated let request_count: Int?
enum CodingKeys: String, CodingKey {
case id, label, auth_type, source, access_token, last_status, request_count
}
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decodeIfPresent(String.self, forKey: .id)
self.label = try c.decodeIfPresent(String.self, forKey: .label)
self.auth_type = try c.decodeIfPresent(String.self, forKey: .auth_type)
self.source = try c.decodeIfPresent(String.self, forKey: .source)
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
}
}
@@ -0,0 +1,267 @@
import Foundation
import AppKit
import os
/// Drives the `hermes auth add <provider> --type oauth` flow via `Process` +
/// pipes instead of SwiftTerm. The embedded terminal approach turned out to
/// have two problems:
///
/// 1. Python's `webbrowser.open` called from a subprocess doesn't reliably
/// open the user's browser the macOS `open` command can fail silently
/// depending on how the parent app was launched.
/// 2. Even when it works, users can't easily copy the URL from a terminal
/// emulator to click or share.
///
/// This controller runs hermes with `--no-browser`, captures stdout/stderr,
/// regex-extracts the authorization URL, and exposes it to the UI as a plain
/// string. The UI shows a real "Open in Browser" button (via NSWorkspace) and
/// a code input text field. Submitting writes the code + newline to hermes's
/// stdin pipe, which Python's `input()` reads normally verified in shell
/// testing that hermes accepts piped stdin when a TTY isn't available.
///
/// Hermes exits 0 even on "login did not return credentials" failures, so we
/// detect success by scanning output for failure markers AND by letting the
/// calling VM reload `auth.json` to see whether a new credential actually
/// landed.
@Observable
@MainActor
final class OAuthFlowController {
private let logger = Logger(subsystem: "com.scarf", category: "OAuthFlowController")
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Observable state
/// Accumulated terminal output for display. Grows monotonically during
/// the flow; cleared on `start(...)`.
var output: String = ""
/// Authorization URL extracted from hermes's output. Shown as a prominent
/// "Open in Browser" button once detected.
var authorizationURL: String?
/// True once hermes has printed the "Authorization code:" prompt. Gates
/// the code submit button so users can't submit too early.
var awaitingCode: Bool = false
/// True between `start(...)` and process termination.
var isRunning: Bool = false
/// Set when the process exits with a success signal (both zero exit AND
/// no failure marker in output). The VM checks this + reloads auth.json.
var succeeded: Bool = false
/// Human-readable error message if start/submit failed mid-flow.
var errorMessage: String?
/// Fired when the process exits, with the raw exit code. Use this to
/// trigger a UI reload or close the sheet.
var onExit: ((Int32) -> Void)?
// MARK: - Private state
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
// MARK: - Lifecycle
/// Start the OAuth flow. Any prior in-flight flow is terminated first.
func start(provider: String, label: String) {
stop()
output = ""
authorizationURL = nil
awaitingCode = false
succeeded = false
errorMessage = nil
// Pass --no-browser so hermes doesn't try (and potentially fail) to
// launch the browser itself we do it explicitly with the button.
var args = ["auth", "add", provider, "--type", "oauth", "--no-browser"]
let trimmedLabel = label.trimmingCharacters(in: .whitespaces)
if !trimmedLabel.isEmpty {
args += ["--label", trimmedLabel]
}
// Use the transport so OAuth works against remote contexts too:
// local spawns hermes directly, remote rounds through ssh -T while
// preserving stdin (for the auth-code prompt) and stdout (for the
// URL parser).
let proc = context.makeTransport().makeProcess(
executable: context.paths.hermesBinary,
args: args
)
if !context.isRemote {
// Only enrich env locally the remote ssh process gets the
// remote login env naturally, and exporting our local API keys
// into it would be wrong.
proc.environment = HermesFileService.enrichedEnvironment()
}
let outPipe = Pipe()
let inPipe = Pipe()
// Merge stderr into stdout: hermes prints the URL + prompt to stdout,
// but diagnostic messages can land on stderr; we want both interleaved
// in display order.
proc.standardOutput = outPipe
proc.standardError = outPipe
proc.standardInput = inPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.isEmpty {
// EOF the peer closed its write end. Drop the handler so
// Foundation doesn't keep calling us with empty reads.
handle.readabilityHandler = nil
return
}
let chunk = String(data: data, encoding: .utf8) ?? ""
// Hop onto the main actor to mutate observable state.
Task { @MainActor [weak self] in
self?.handleOutputChunk(chunk)
}
}
proc.terminationHandler = { [weak self] p in
let code = p.terminationStatus
Task { @MainActor [weak self] in
outPipe.fileHandleForReading.readabilityHandler = nil
self?.handleTermination(exitCode: code)
}
}
do {
try proc.run()
process = proc
stdinPipe = inPipe
stdoutPipe = outPipe
isRunning = true
} catch {
errorMessage = "Failed to start hermes: \(error.localizedDescription)"
logger.error("Failed to start hermes: \(error.localizedDescription)")
}
}
/// Terminate the in-flight process (if any). Safe to call when nothing is running.
func stop() {
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
process = nil
stdinPipe = nil
stdoutPipe = nil
isRunning = false
awaitingCode = false
}
/// Send the authorization code to hermes's stdin. Called when the user
/// taps "Submit" in the sheet's code input field.
func submitCode(_ code: String) {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
errorMessage = "Authorization code is empty"
return
}
guard let stdinPipe else {
errorMessage = "Process is no longer accepting input"
return
}
let payload = trimmed + "\n"
guard let data = payload.data(using: .utf8) else {
errorMessage = "Could not encode code"
return
}
do {
try stdinPipe.fileHandleForWriting.write(contentsOf: data)
// After writing, we don't close stdin hermes might prompt again
// on failure. Instead we flip `awaitingCode` off so the UI can
// dim the submit button until another prompt appears.
awaitingCode = false
} catch {
errorMessage = "Failed to send code: \(error.localizedDescription)"
}
}
/// Explicitly open the detected authorization URL in the default browser.
/// Does nothing if no URL has been detected yet.
func openURLInBrowser() {
guard let url = authorizationURL, let parsed = URL(string: url) else { return }
NSWorkspace.shared.open(parsed)
}
// MARK: - Output handling
private func handleOutputChunk(_ chunk: String) {
output += chunk
if authorizationURL == nil, let url = Self.extractAuthURL(from: output) {
authorizationURL = url
// Auto-open the browser on first detection, since that's what a
// well-behaved hermes would have done. We keep the manual button
// available for retries / copy-paste.
if let parsed = URL(string: url) {
NSWorkspace.shared.open(parsed)
}
}
// The prompt may arrive in the same chunk as the URL. Checking
// cumulative output (rather than just this chunk) is safer.
if !awaitingCode, output.contains("Authorization code:") {
awaitingCode = true
}
}
private func handleTermination(exitCode: Int32) {
isRunning = false
// Hermes exits 0 even on "login did not return credentials" detect
// that failure marker explicitly so we don't report false success.
let failureMarkers = [
"did not return credentials",
"Token exchange failed",
"OAuth login failed",
"HTTP Error"
]
let outputFailed = failureMarkers.contains { output.localizedCaseInsensitiveContains($0) }
succeeded = exitCode == 0 && !outputFailed
if !succeeded, errorMessage == nil {
if outputFailed {
errorMessage = "OAuth did not complete — check the output above for details"
} else if exitCode != 0 {
errorMessage = "hermes exited with code \(exitCode)"
}
}
onExit?(exitCode)
}
// MARK: - URL extraction
/// Extract the OAuth authorization URL from hermes's output. Hermes prints
/// it on its own line in a Rich-rendered box; we want a plain https URL
/// that looks like a provider OAuth endpoint.
///
/// Priority order:
/// 1. URLs containing `client_id=` real OAuth auth URLs always have this.
/// 2. URLs containing `/authorize` fallback for providers that don't
/// include client_id in the query (unusual but possible).
/// 3. URLs containing `/oauth/` last resort.
///
/// Docs URLs and generic callback URLs are filtered out by these checks.
nonisolated static func extractAuthURL(from text: String) -> String? {
let pattern = #"https://[^\s\)\]\"'`<>]+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..., in: text)
let urls: [String] = regex.matches(in: text, range: range).compactMap { match in
Range(match.range, in: text).map { String(text[$0]) }
}
// Prefer the strongest signal so we don't accidentally surface the
// redirect callback URL when both appear unencoded in output.
if let url = urls.first(where: { $0.contains("client_id=") }) { return url }
if let url = urls.first(where: { $0.contains("/authorize") }) { return url }
if let url = urls.first(where: { $0.contains("/oauth/") }) { return url }
return nil
}
}
@@ -0,0 +1,482 @@
import SwiftUI
struct CredentialPoolsView: View {
@State private var viewModel: CredentialPoolsViewModel
@State private var showAddSheet = false
@State private var pendingRemove: HermesCredential?
init(context: ServerContext) {
_viewModel = State(initialValue: CredentialPoolsViewModel(context: context))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
header
safetyNotice
if viewModel.isLoading {
ProgressView().padding()
} else if viewModel.pools.isEmpty {
emptyState
} else {
ForEach(viewModel.pools) { pool in
poolSection(pool)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Credential Pools")
.loadingOverlay(
viewModel.isLoading,
label: "Loading credentials…",
isEmpty: viewModel.pools.isEmpty
)
.onAppear { viewModel.load() }
.sheet(isPresented: $showAddSheet) {
AddCredentialSheet(viewModel: viewModel) {
showAddSheet = false
}
}
.confirmationDialog(
pendingRemove.map { "Remove credential for \($0.provider)?" } ?? "",
isPresented: Binding(get: { pendingRemove != nil }, set: { if !$0 { pendingRemove = nil } })
) {
Button("Remove", role: .destructive) {
if let target = pendingRemove {
viewModel.removeCredential(provider: target.provider, index: target.index)
}
pendingRemove = nil
}
Button("Cancel", role: .cancel) { pendingRemove = nil }
} message: {
Text("This removes the credential from hermes. The upstream provider key is not revoked.")
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showAddSheet = true
} label: {
Label("Add Credential", systemImage: "plus")
}
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
}
private var safetyNotice: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "lock.shield")
.foregroundStyle(.secondary)
Text("API keys are never displayed in full. Scarf only shows the last 4 characters for identification. Full key values are stored by hermes in ~/.hermes/auth.json.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "key.horizontal")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("No credential pools configured")
.foregroundStyle(.secondary)
Text("Add rotation credentials so hermes can failover between keys when one hits rate limits.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
@ViewBuilder
private func poolSection(_ pool: HermesCredentialPool) -> some View {
SettingsSection(title: pool.provider, icon: "key.horizontal") {
PickerRow(label: "Rotation", selection: pool.strategy, options: viewModel.strategyOptions) { strategy in
viewModel.setStrategy(strategy, for: pool.provider)
}
ForEach(pool.credentials) { cred in
HStack(spacing: 12) {
Image(systemName: cred.authType == "oauth" ? "person.badge.key" : "key.fill")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("#\(cred.index + 1)")
.font(.system(.caption, design: .monospaced, weight: .bold))
if !cred.label.isEmpty {
Text(cred.label).font(.caption)
}
if !cred.authType.isEmpty {
Text(cred.authType)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary)
.clipShape(Capsule())
}
if !cred.lastStatus.isEmpty {
Text(cred.lastStatus)
.font(.caption2)
.foregroundStyle(statusColor(cred.lastStatus))
}
}
HStack(spacing: 8) {
Text(cred.tokenTail.isEmpty ? "" : cred.tokenTail)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
if !cred.source.isEmpty {
Text(cred.source)
.font(.caption2)
.foregroundStyle(.tertiary)
}
if cred.requestCount > 0 {
Text("\(cred.requestCount) req")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
Spacer()
Button("Remove", role: .destructive) { pendingRemove = cred }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
HStack {
Spacer()
Button("Reset Cooldowns") { viewModel.resetProvider(pool.provider) }
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
private func statusColor(_ status: String) -> Color {
switch status {
case "ok", "active": return .green
case "cooldown": return .orange
case "exhausted": return .red
default: return .secondary
}
}
}
/// Two-step sheet for adding a credential:
/// 1. Provider picker (populated from the models catalog, falls back to free text)
/// + type selector (API Key vs OAuth) + optional label
/// 2. Either an immediate save (API key) or an embedded terminal running the
/// OAuth flow so the user can paste the authorization code back.
private struct AddCredentialSheet: View {
@Bindable var viewModel: CredentialPoolsViewModel
let onDismiss: () -> Void
enum AuthType: String, CaseIterable, Identifiable {
case apiKey = "API Key"
case oauth = "OAuth"
var id: String { rawValue }
}
@State private var providerID: String = ""
@State private var authType: AuthType = .apiKey
@State private var apiKey: String = ""
@State private var label: String = ""
@State private var providers: [HermesProviderInfo] = []
@State private var oauthStarted: Bool = false
@State private var authCode: String = ""
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Add Credential")
.font(.headline)
if !oauthStarted {
configSection
} else {
oauthSection
}
Divider()
footer
}
.padding()
.frame(minWidth: 600, minHeight: 460)
.onAppear {
providers = catalog.loadProviders()
}
// Auto-close the sheet once a credential is actually saved. We key
// off `succeeded` which the controller sets only when hermes exited
// zero AND the output has no failure markers. The 0.8s delay lets the
// user see the success banner before the sheet disappears.
.onChange(of: viewModel.oauthFlow.succeeded) { _, newValue in
guard newValue else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
onDismiss()
}
}
}
// MARK: - Step 1: provider + type + label + optional API key
private var configSection: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Provider").font(.caption).foregroundStyle(.secondary)
HStack {
// Free-text first so providers missing from the catalog
// (e.g. "nous") are still addable.
TextField("e.g. anthropic", text: $providerID)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Menu("Browse") {
ForEach(providers) { provider in
Button(provider.providerName + " (\(provider.providerID))") {
providerID = provider.providerID
}
}
}
.controlSize(.small)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Credential Type").font(.caption).foregroundStyle(.secondary)
Picker("", selection: $authType) {
ForEach(AuthType.allCases) { type in
Text(type.rawValue).tag(type)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 4) {
Text("Label (optional)").font(.caption).foregroundStyle(.secondary)
TextField("e.g. team-prod", text: $label)
.textFieldStyle(.roundedBorder)
}
if authType == .apiKey {
VStack(alignment: .leading, spacing: 4) {
Text("API Key").font(.caption).foregroundStyle(.secondary)
SecureField("sk-…", text: $apiKey)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
}
} else {
oauthPreamble
}
}
}
/// Brief explanation shown before the user clicks "Start OAuth". Sets
/// expectations about the embedded-terminal flow so the browser window
/// and code-paste step aren't surprises.
private var oauthPreamble: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next.")
.font(.caption)
.foregroundStyle(.secondary)
Text("The terminal is a real TTY — paste with ⌘V, press Return, and wait for the process to exit with \"login succeeded\".")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
// MARK: - Step 2: OAuth URL button, code field, live output log
private var oauthSection: some View {
// Pull the observable controller into a local so the view redraws
// when its @Observable properties change.
let flow = viewModel.oauthFlow
return VStack(alignment: .leading, spacing: 10) {
oauthHeader(flow: flow)
urlBlock(flow: flow)
codeEntryBlock(flow: flow)
outputLogBlock(flow: flow)
}
}
@ViewBuilder
private func oauthHeader(flow: OAuthFlowController) -> some View {
HStack(spacing: 8) {
Image(systemName: "person.badge.key")
Text("OAuth login for \(viewModel.oauthProvider)")
.font(.headline)
Spacer()
if flow.isRunning {
ProgressView().controlSize(.small)
} else if flow.succeeded {
Label("Succeeded", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else if let err = flow.errorMessage {
Label(err, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(1)
}
}
}
/// Authorization URL block. Hermes prints the URL on startup; we detect
/// it via regex and expose a prominent Open + Copy pair. The URL keeps
/// showing even after the browser is opened so users can paste it into
/// a different browser profile if needed.
@ViewBuilder
private func urlBlock(flow: OAuthFlowController) -> some View {
if let url = flow.authorizationURL {
VStack(alignment: .leading, spacing: 6) {
Label("Authorization URL", systemImage: "link")
.font(.caption.bold())
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Text(url)
.font(.caption.monospaced())
.textSelection(.enabled)
.lineLimit(2)
.truncationMode(.middle)
Spacer()
Button {
flow.openURLInBrowser()
} label: {
Label("Open in Browser", systemImage: "safari")
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.controlSize(.small)
}
}
.padding(8)
.background(.blue.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 6))
} else if flow.isRunning {
// Still waiting for hermes to print the URL usually <1s.
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Waiting for authorization URL…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// Authorization code input. Only active once hermes has printed its
/// "Authorization code:" prompt so users can't submit before hermes is
/// ready to receive input.
@ViewBuilder
private func codeEntryBlock(flow: OAuthFlowController) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Authorization Code", systemImage: "keyboard")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text("After approving in your browser, the provider shows a code. Paste it below and submit.")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
TextField("Paste code here…", text: $authCode)
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
.disabled(!flow.awaitingCode)
.onSubmit { submitCode(flow: flow) }
Button("Submit") { submitCode(flow: flow) }
.controlSize(.small)
.buttonStyle(.borderedProminent)
.disabled(!flow.awaitingCode || authCode.trimmingCharacters(in: .whitespaces).isEmpty)
}
if !flow.awaitingCode && flow.isRunning {
Text("Waiting for hermes to prompt for the code…")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
/// Live output log useful for diagnostics if the flow stalls or errors.
@ViewBuilder
private func outputLogBlock(flow: OAuthFlowController) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Output", systemImage: "text.alignleft")
.font(.caption.bold())
.foregroundStyle(.secondary)
ScrollView {
Text(flow.output.isEmpty ? "(no output yet)" : flow.output)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
.frame(minHeight: 120, maxHeight: 200)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
private func submitCode(flow: OAuthFlowController) {
let trimmed = authCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
viewModel.submitOAuthCode(trimmed)
authCode = ""
}
// MARK: - Footer (buttons)
private var footer: some View {
HStack {
Spacer()
if oauthStarted {
Button("Close") {
// Closing mid-flow terminates hermes so we don't leave a
// zombie process waiting for stdin forever.
viewModel.cancelOAuth()
onDismiss()
}
} else {
Button("Cancel") { onDismiss() }
if authType == .apiKey {
Button("Add") {
viewModel.addAPIKey(provider: providerID, apiKey: apiKey, label: label)
onDismiss()
}
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
} else {
Button("Start OAuth") {
viewModel.startOAuth(provider: providerID, label: label)
oauthStarted = true
}
.buttonStyle(.borderedProminent)
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
}
@@ -1,19 +1,126 @@
import Foundation import Foundation
import AppKit
import os
@Observable @Observable
final class CronViewModel { final class CronViewModel {
private let fileService = HermesFileService() private let logger = Logger(subsystem: "com.scarf", category: "CronViewModel")
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
var jobs: [HermesCronJob] = [] var jobs: [HermesCronJob] = []
var selectedJob: HermesCronJob? var selectedJob: HermesCronJob?
var jobOutput: String? var jobOutput: String?
var availableSkills: [String] = []
var message: String?
var showCreateSheet = false
var editingJob: HermesCronJob?
var isLoading = false
func load() { func load() {
jobs = fileService.loadCronJobs() isLoading = true
let svc = fileService
let selectedID = selectedJob?.id
Task.detached { [weak self] in
// Three sync transport ops on remote keep them off main.
let jobs = svc.loadCronJobs()
let skills = svc.loadSkills().flatMap { $0.skills.map(\.id) }.sorted()
let refreshed = selectedID.flatMap { id in jobs.first(where: { $0.id == id }) }
let output = refreshed.flatMap { svc.loadCronOutput(jobId: $0.id) }
await MainActor.run { [weak self] in
guard let self else { return }
self.jobs = jobs
self.availableSkills = skills
if let refreshed { self.selectedJob = refreshed }
if output != nil { self.jobOutput = output }
self.isLoading = false
}
}
} }
func selectJob(_ job: HermesCronJob) { func selectJob(_ job: HermesCronJob) {
selectedJob = job selectedJob = job
jobOutput = fileService.loadCronOutput(jobId: job.id) let svc = fileService
let jobID = job.id
Task.detached { [weak self] in
let output = svc.loadCronOutput(jobId: jobID)
await MainActor.run { [weak self] in self?.jobOutput = output }
}
}
// MARK: - CLI wrappers
func pauseJob(_ job: HermesCronJob) {
runAndReload(["cron", "pause", job.id], success: "Paused")
}
func resumeJob(_ job: HermesCronJob) {
runAndReload(["cron", "resume", job.id], success: "Resumed")
}
func runNow(_ job: HermesCronJob) {
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
}
func deleteJob(_ job: HermesCronJob) {
runAndReload(["cron", "remove", job.id], success: "Removed")
if selectedJob?.id == job.id {
selectedJob = nil
jobOutput = nil
}
}
func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String) {
var args = ["cron", "create"]
if !name.isEmpty { args += ["--name", name] }
if !deliver.isEmpty { args += ["--deliver", deliver] }
if !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
for skill in skills where !skill.isEmpty { args += ["--skill", skill] }
if !script.isEmpty { args += ["--script", script] }
args.append(schedule)
if !prompt.isEmpty { args.append(prompt) }
runAndReload(args, success: "Job created")
}
func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?) {
var args = ["cron", "edit", id]
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
if let name, !name.isEmpty { args += ["--name", name] }
if let deliver { args += ["--deliver", deliver] }
if let repeatCount, !repeatCount.isEmpty { args += ["--repeat", repeatCount] }
if clearSkills {
args.append("--clear-skills")
} else if let newSkills {
for skill in newSkills where !skill.isEmpty { args += ["--skill", skill] }
}
if let script { args += ["--script", script] }
runAndReload(args, success: "Updated")
}
// MARK: - Private
private func runAndReload(_ arguments: [String], success: String) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: arguments, timeout: 60)
await MainActor.run {
if result.exitCode == 0 {
self.message = success
} else {
self.message = "Failed: \(result.output.prefix(200))"
self.logger.warning("cron command failed: args=\(arguments) output=\(result.output)")
}
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
} }
} }
+284 -8
View File
@@ -1,20 +1,93 @@
import SwiftUI import SwiftUI
struct CronView: View { struct CronView: View {
@State private var viewModel = CronViewModel() @State private var viewModel: CronViewModel
@State private var pendingDelete: HermesCronJob?
init(context: ServerContext) {
_viewModel = State(initialValue: CronViewModel(context: context))
}
var body: some View { var body: some View {
HSplitView { HSplitView {
jobsList jobsList
.frame(minWidth: 300, idealWidth: 350) .frame(minWidth: 320, idealWidth: 360)
jobDetail jobDetail
.frame(minWidth: 400) .frame(minWidth: 400)
} }
.navigationTitle("Cron Jobs") .navigationTitle("Cron Jobs")
.loadingOverlay(viewModel.isLoading, label: "Loading cron jobs…", isEmpty: viewModel.jobs.isEmpty)
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.sheet(isPresented: $viewModel.showCreateSheet) {
CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills) { form in
viewModel.createJob(
schedule: form.schedule,
prompt: form.prompt,
name: form.name,
deliver: form.deliver,
skills: form.skills,
script: form.script,
repeatCount: form.repeatCount
)
viewModel.showCreateSheet = false
} onCancel: {
viewModel.showCreateSheet = false
}
}
.sheet(item: $viewModel.editingJob) { job in
CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills) { form in
viewModel.updateJob(
id: job.id,
schedule: form.schedule,
prompt: form.prompt,
name: form.name,
deliver: form.deliver,
repeatCount: form.repeatCount,
newSkills: form.skills,
clearSkills: form.clearSkills,
script: form.script
)
viewModel.editingJob = nil
} onCancel: {
viewModel.editingJob = nil
}
}
.confirmationDialog(
pendingDelete.map { "Delete \($0.name)?" } ?? "",
isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } })
) {
Button("Delete", role: .destructive) {
if let job = pendingDelete { viewModel.deleteJob(job) }
pendingDelete = nil
}
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: {
Text("This removes the scheduled job permanently.")
}
} }
private var jobsList: some View { private var jobsList: some View {
VStack(spacing: 0) {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "info.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
viewModel.showCreateSheet = true
} label: {
Label("Add", systemImage: "plus")
}
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 6)
Divider()
List(selection: Binding( List(selection: Binding(
get: { viewModel.selectedJob?.id }, get: { viewModel.selectedJob?.id },
set: { id in set: { id in
@@ -38,6 +111,11 @@ struct CronView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if job.silent == true {
Text("SILENT")
.font(.caption2.bold())
.foregroundStyle(.purple)
}
if !job.enabled { if !job.enabled {
Text("Disabled") Text("Disabled")
.font(.caption2) .font(.caption2)
@@ -45,6 +123,19 @@ struct CronView: View {
} }
} }
.tag(job.id) .tag(job.id)
.contextMenu {
Button(job.enabled ? "Pause" : "Resume") {
if job.enabled {
viewModel.pauseJob(job)
} else {
viewModel.resumeJob(job)
}
}
Button("Run Now") { viewModel.runNow(job) }
Button("Edit") { viewModel.editingJob = job }
Divider()
Button("Delete", role: .destructive) { pendingDelete = job }
}
} }
} }
.listStyle(.inset) .listStyle(.inset)
@@ -54,12 +145,28 @@ struct CronView: View {
} }
} }
} }
}
@ViewBuilder @ViewBuilder
private var jobDetail: some View { private var jobDetail: some View {
if let job = viewModel.selectedJob { if let job = viewModel.selectedJob {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
detailHeader(job)
actionBar(job)
Divider()
detailBody(job)
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
} else {
ContentUnavailableView("Select a Job", systemImage: "clock.arrow.2.circlepath", description: Text("Choose a cron job from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func detailHeader(_ job: HermesCronJob) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(job.name) Text(job.name)
.font(.title2.bold()) .font(.title2.bold())
@@ -67,14 +174,44 @@ struct CronView: View {
Label(job.state, systemImage: job.stateIcon) Label(job.state, systemImage: job.stateIcon)
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock") Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle") 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") Label("Deliver: \(deliver)", systemImage: "paperplane")
} }
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Divider() }
private func actionBar(_ job: HermesCronJob) -> some View {
HStack(spacing: 8) {
Button {
if job.enabled { viewModel.pauseJob(job) } else { viewModel.resumeJob(job) }
} label: {
Label(job.enabled ? "Pause" : "Resume", systemImage: job.enabled ? "pause" : "play")
}
Button {
viewModel.runNow(job)
} label: {
Label("Run Now", systemImage: "bolt")
}
Button {
viewModel.editingJob = job
} label: {
Label("Edit", systemImage: "pencil")
}
Spacer()
Button(role: .destructive) {
pendingDelete = job
} label: {
Label("Delete", systemImage: "trash")
}
}
.controlSize(.small)
}
@ViewBuilder
private func detailBody(_ job: HermesCronJob) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Prompt") Text("Prompt")
.font(.caption.bold()) .font(.caption.bold())
@@ -86,6 +223,20 @@ struct CronView: View {
.background(.quaternary.opacity(0.5)) .background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6)) .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 { if let skills = job.skills, !skills.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")
@@ -118,6 +269,21 @@ struct CronView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.red) .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 { if let output = viewModel.jobOutput {
Divider() Divider()
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -134,12 +300,122 @@ struct CronView: View {
} }
} }
} }
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
/// Create/edit sheet. Form fields mirror `hermes cron create|edit` flags.
struct CronJobEditor: View {
enum Mode {
case create
case edit(HermesCronJob)
}
struct FormState {
var name: String = ""
var schedule: String = ""
var prompt: String = ""
var deliver: String = ""
var repeatCount: String = ""
var skills: [String] = []
var clearSkills: Bool = false
var script: String = ""
}
let mode: Mode
let availableSkills: [String]
let onSave: (FormState) -> Void
let onCancel: () -> Void
@State private var form = FormState()
@State private var isEditMode = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(headerText)
.font(.headline)
formField("Name", text: $form.name, placeholder: "Friendly label")
formField("Schedule", text: $form.schedule, placeholder: "0 9 * * * or 30m or every 2h", mono: true)
VStack(alignment: .leading, spacing: 4) {
Text("Prompt")
.font(.caption).foregroundStyle(.secondary)
TextEditor(text: $form.prompt)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 100)
.padding(4)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
if !availableSkills.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Skills")
.font(.caption).foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(availableSkills, id: \.self) { skill in
Toggle(skill, isOn: Binding(
get: { form.skills.contains(skill) },
set: { on in
if on {
form.skills.append(skill)
} else { } else {
ContentUnavailableView("Select a Job", systemImage: "clock.arrow.2.circlepath", description: Text("Choose a cron job from the list")) form.skills.removeAll { $0 == skill }
.frame(maxWidth: .infinity, maxHeight: .infinity) }
}
))
.font(.caption.monospaced())
.toggleStyle(.checkbox)
}
}
}
.frame(maxHeight: 120)
.padding(6)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
if isEditMode {
Toggle("Clear all skills on save", isOn: $form.clearSkills)
.font(.caption)
}
}
}
HStack {
Spacer()
Button("Cancel") { onCancel() }
Button("Save") { onSave(form) }
.buttonStyle(.borderedProminent)
.disabled(form.schedule.isEmpty)
}
}
.padding()
.frame(minWidth: 560, minHeight: 560)
.onAppear {
if case .edit(let job) = mode {
isEditMode = true
form.name = job.name
form.schedule = job.schedule.expression ?? job.schedule.display ?? ""
form.prompt = job.prompt
form.deliver = job.deliver ?? ""
form.skills = job.skills ?? []
form.script = job.preRunScript ?? ""
}
}
}
private var headerText: String {
switch mode {
case .create: return "Create Cron Job"
case .edit(let job): return "Edit \(job.name)"
}
}
@ViewBuilder
private func formField(_ label: String, text: Binding<String>, placeholder: String, mono: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.caption).foregroundStyle(.secondary)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.font(mono ? .system(.caption, design: .monospaced) : .caption)
} }
} }
} }
@@ -2,13 +2,18 @@ import Foundation
@Observable @Observable
final class DashboardViewModel { final class DashboardViewModel {
private let dataService = HermesDataService() let context: ServerContext
private let fileService = HermesFileService() private let dataService: HermesDataService
private let fileService: HermesFileService
var stats = HermesDataService.SessionStats( init(context: ServerContext = .local) {
totalSessions: 0, totalMessages: 0, totalToolCalls: 0, self.context = context
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0 self.dataService = HermesDataService(context: context)
) self.fileService = HermesFileService(context: context)
}
var stats = HermesDataService.SessionStats.empty
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var config = HermesConfig.empty var config = HermesConfig.empty
@@ -16,18 +21,73 @@ final class DashboardViewModel {
var hermesRunning = false var hermesRunning = false
var isLoading = true var isLoading = true
/// User-presentable error banner. Set when any of the remote reads
/// (state.db snapshot, config.yaml, gateway_state.json, pgrep) failed
/// in a way that's not just "file doesn't exist yet". Dashboard renders
/// this above the stats with a "Run Diagnostics" button. `nil` = no
/// surfaceable error.
var lastReadError: String?
func load() async { func load() async {
isLoading = true isLoading = true
let opened = await dataService.open() // refresh() = close + reopen, forces a fresh remote snapshot. Cheap
// on local (live DB reopen).
let opened = await dataService.refresh()
var collectedErrors: [String] = []
if opened { if opened {
stats = await dataService.fetchStats() stats = await dataService.fetchStats()
recentSessions = await dataService.fetchSessions(limit: 5) recentSessions = await dataService.fetchSessions(limit: 5)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 5) sessionPreviews = await dataService.fetchSessionPreviews(limit: 5)
await dataService.close() await dataService.close()
} else if let msg = await dataService.lastOpenError {
collectedErrors.append(msg)
}
// The fileService methods are synchronous and route through the
// transport. For remote contexts each call is a blocking ssh
// round-trip do them off the main thread to avoid spinning the
// beach ball during the load.
let svc = fileService
struct LoadResults: Sendable {
let cfg: Result<HermesConfig, Error>
let gw: Result<GatewayState?, Error>
let running: Result<pid_t?, Error>
}
let results = await Task.detached { () -> LoadResults in
LoadResults(
cfg: svc.loadConfigResult(),
gw: svc.loadGatewayStateResult(),
running: svc.hermesPIDResult()
)
}.value
switch results.cfg {
case .success(let c): config = c
case .failure(let e):
config = .empty
collectedErrors.append("config.yaml — \(e.localizedDescription)")
}
switch results.gw {
case .success(let g): gatewayState = g
case .failure(let e):
gatewayState = nil
collectedErrors.append("gateway_state.json — \(e.localizedDescription)")
}
switch results.running {
case .success(let pid): hermesRunning = (pid != nil)
case .failure(let e):
hermesRunning = false
collectedErrors.append("pgrep — \(e.localizedDescription)")
}
// Only surface when there's a real error AND we're on a remote
// context. Local contexts rarely hit these paths (live DB, local
// filesystem), and a transient "file doesn't exist yet" on fresh
// installs shouldn't scare users.
if context.isRemote, !collectedErrors.isEmpty {
lastReadError = collectedErrors.joined(separator: "\n")
} else {
lastReadError = nil
} }
config = fileService.loadConfig()
gatewayState = fileService.loadGatewayState()
hermesRunning = fileService.isHermesRunning()
isLoading = false isLoading = false
} }
} }
@@ -1,13 +1,22 @@
import SwiftUI import SwiftUI
struct DashboardView: View { struct DashboardView: View {
@State private var viewModel = DashboardViewModel() @State private var viewModel: DashboardViewModel
@State private var showDiagnostics = false
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: DashboardViewModel(context: context))
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
if let err = viewModel.lastReadError {
readErrorBanner(err)
}
statusSection statusSection
statsSection statsSection
recentSessionsSection recentSessionsSection
@@ -16,10 +25,53 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
} }
.navigationTitle("Dashboard") .navigationTitle("Dashboard")
.loadingOverlay(
viewModel.isLoading,
label: "Loading dashboard…",
isEmpty: viewModel.recentSessions.isEmpty
)
.task { await viewModel.load() } .task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() } Task { await viewModel.load() }
} }
.sheet(isPresented: $showDiagnostics) {
RemoteDiagnosticsView(context: viewModel.context)
}
}
/// Banner shown above the Dashboard when one or more remote reads
/// failed (permission denied, missing sqlite3, wrong home dir, etc.).
/// Replaces the old silent-failure mode where empty values just
/// appeared as "Stopped / unknown / 0" with no explanation.
private func readErrorBanner(_ err: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Can't read Hermes state on \(viewModel.context.displayName)")
.font(.headline)
Text(err)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
showDiagnostics = true
} label: {
Label("Run Diagnostics…", systemImage: "stethoscope")
}
.controlSize(.regular)
}
}
.padding(12)
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.orange.opacity(0.3), lineWidth: 1)
)
} }
private var statusSection: some View { private var statusSection: some View {
@@ -60,6 +112,10 @@ struct DashboardView: View {
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)") StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)") StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens)) StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 {
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
}
} }
} }
} }
@@ -90,14 +146,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 { struct StatusCard: View {
@@ -168,6 +216,9 @@ struct SessionRow: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left") Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench") Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -19,17 +19,7 @@ struct PlatformInfo: Identifiable {
var isConnected: Bool { state == "connected" } var isConnected: Bool { state == "connected" }
var icon: String { var icon: String { KnownPlatforms.icon(for: name) }
switch name {
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
default: return "bubble.left"
}
}
} }
struct PairedUser: Identifiable { struct PairedUser: Identifiable {
@@ -47,6 +37,12 @@ struct PendingPairing: Identifiable {
@Observable @Observable
final class GatewayViewModel { final class GatewayViewModel {
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
}
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
var approvedUsers: [PairedUser] = [] var approvedUsers: [PairedUser] = []
var pendingPairings: [PendingPairing] = [] var pendingPairings: [PendingPairing] = []
@@ -55,52 +51,26 @@ final class GatewayViewModel {
func load() { func load() {
isLoading = true isLoading = true
loadGatewayStatus() let ctx = context
loadPairing() Task.detached { [weak self] in
isLoading = false // Two sync transport calls + two CLI invocations substantial
// remote latency. Detach the whole load and commit at the end.
let status = Self.fetchGatewayStatus(context: ctx)
let pairing = Self.fetchPairing(context: ctx)
await MainActor.run { [weak self] in
guard let self else { return }
self.gateway = status
self.approvedUsers = pairing.approved
self.pendingPairings = pairing.pending
self.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() { /// Static form of the gateway-status walk so the detached load can call
runHermes(["gateway", "stop"]) /// it without bouncing back to MainActor.
actionMessage = "Gateway stop requested" nonisolated private static func fetchGatewayStatus(context: ServerContext) -> GatewayInfo {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in let stateJSON = context.readData(context.paths.gatewayStateJSON)
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 pid: Int?
var state = "unknown" var state = "unknown"
var exitReason: String? var exitReason: String?
@@ -127,21 +97,21 @@ final class GatewayViewModel {
} }
} }
let statusOutput = runHermes(["gateway", "status"]).output let statusOutput = context.runHermes(["gateway", "status"]).output
let isLoaded = statusOutput.contains("service is loaded") let isLoaded = statusOutput.contains("service is loaded")
let isStale = statusOutput.contains("stale") let isStale = statusOutput.contains("stale")
gateway = GatewayInfo( return GatewayInfo(
pid: pid, state: state, exitReason: exitReason, pid: pid, state: state, exitReason: exitReason,
startTime: startTime, updatedAt: updatedAt, startTime: startTime, updatedAt: updatedAt,
platforms: platforms, isLoaded: isLoaded, isStale: isStale platforms: platforms, isLoaded: isLoaded, isStale: isStale
) )
} }
private func loadPairing() { nonisolated private static func fetchPairing(context: ServerContext) -> (approved: [PairedUser], pending: [PendingPairing]) {
let output = runHermes(["pairing", "list"]).output let output = context.runHermes(["pairing", "list"]).output
approvedUsers = [] var approved: [PairedUser] = []
pendingPairings = [] var pending: [PendingPairing] = []
var inApproved = false var inApproved = false
var inPending = false var inPending = false
@@ -157,31 +127,59 @@ final class GatewayViewModel {
let platform = String(parts[0]) let platform = String(parts[0])
let userId = String(parts[1]) let userId = String(parts[1])
let name = parts[2...].joined(separator: " ") let name = parts[2...].joined(separator: " ")
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name)) approved.append(PairedUser(platform: platform, userId: userId, name: name))
} } else if inPending && parts.count >= 2 {
if inPending && parts.count >= 2 {
let platform = String(parts[0]) let platform = String(parts[0])
let code = String(parts[1]) let code = String(parts[1])
pendingPairings.append(PendingPairing(platform: platform, code: code)) pending.append(PendingPairing(platform: platform, code: code))
} }
} }
return (approved, pending)
}
func startGateway() {
runHermes(["gateway", "start"])
actionMessage = "Gateway start requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.load()
self?.actionMessage = nil
}
} }
func stopGateway() {
runHermes(["gateway", "stop"])
actionMessage = "Gateway stop requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.load()
self?.actionMessage = nil
}
}
func restartGateway() {
runHermes(["gateway", "restart"])
actionMessage = "Gateway restart requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.load()
self?.actionMessage = nil
}
}
func approvePairing(platform: String, code: String) {
runHermes(["pairing", "approve", platform, code])
load()
}
func revokeUser(_ user: PairedUser) {
runHermes(["pairing", "revoke", user.platform, user.userId])
approvedUsers.removeAll { $0.id == user.id }
}
// MARK: - Private
// (loadGatewayStatus / loadPairing were moved to static helpers above
// so the detached load() can run them without touching MainActor state.)
@discardableResult @discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process() context.runHermes(arguments)
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)
}
} }
} }
@@ -1,9 +1,14 @@
import SwiftUI import SwiftUI
struct GatewayView: View { struct GatewayView: View {
@State private var viewModel = GatewayViewModel() @State private var viewModel: GatewayViewModel
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: GatewayViewModel(context: context))
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
@@ -177,15 +182,7 @@ struct GatewayView: View {
} }
private func platformIcon(_ platform: String) -> String { private func platformIcon(_ platform: String) -> String {
switch platform { KnownPlatforms.icon(for: platform)
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
default: return "bubble.left"
}
} }
} }
@@ -22,6 +22,15 @@ struct HealthSection: Identifiable {
@Observable @Observable
final class HealthViewModel { final class HealthViewModel {
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
var version = "" var version = ""
var updateInfo = "" var updateInfo = ""
var hasUpdate = false var hasUpdate = false
@@ -31,16 +40,90 @@ final class HealthViewModel {
var warningCount = 0 var warningCount = 0
var okCount = 0 var okCount = 0
var isLoading = false var isLoading = false
var hermesRunning = false
var hermesPID: pid_t?
var actionMessage: String?
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
var diagnosticsOutput: String = ""
var isSharingDebug = false
func load() { func load() {
isLoading = true isLoading = true
loadVersion() let ctx = context
let statusOutput = runHermes(["status"]).output let svc = fileService
statusSections = parseOutput(statusOutput) // Health runs four sync transport-mediated commands plus a process
let doctorOutput = runHermes(["doctor"]).output // probe that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
doctorSections = parseOutput(doctorOutput) // the whole load.
computeCounts() Task.detached { [weak self] in
isLoading = false let pid = svc.hermesPID()
let versionOutput = ctx.runHermes(["version"]).output
let statusOutput = ctx.runHermes(["status"]).output
let doctorOutput = ctx.runHermes(["doctor"]).output
let lines = versionOutput.components(separatedBy: "\n")
let version = lines.first ?? ""
let updateLine = lines.first(where: { $0.contains("commits behind") })
let hasUpdate = updateLine != nil
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
let statusSections = Self.parseOutputStatic(statusOutput)
let doctorSections = Self.parseOutputStatic(doctorOutput)
await MainActor.run { [weak self] in
guard let self else { return }
self.hermesPID = pid
self.hermesRunning = pid != nil
self.version = version
self.updateInfo = updateInfo
self.hasUpdate = hasUpdate
self.statusSections = statusSections
self.doctorSections = doctorSections
self.computeCounts()
self.isLoading = false
}
}
}
func refreshProcessStatus() {
let svc = fileService
Task.detached { [weak self] in
let pid = svc.hermesPID()
await MainActor.run { [weak self] in
self?.hermesPID = pid
self?.hermesRunning = pid != 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() { private func loadVersion() {
@@ -56,6 +139,96 @@ final class HealthViewModel {
} }
} }
/// Static-callable form for the detached load() task. The instance
/// `parseOutput` below delegates here so existing call sites still work.
nonisolated static func parseOutputStatic(_ 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: iconForSectionStatic(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheckStatic(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) = splitCheckStatic(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheckStatic(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: iconForSectionStatic(currentTitle),
checks: currentChecks
))
}
return sections
}
nonisolated private static func splitCheckStatic(_ text: String) -> (String, String?) {
if let range = text.range(of: ":") {
let label = String(text[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
let detail = String(text[range.upperBound...]).trimmingCharacters(in: .whitespaces)
return (label, detail.isEmpty ? nil : detail)
}
return (text, nil)
}
nonisolated private static func iconForSectionStatic(_ title: String) -> String {
let lower = title.lowercased()
if lower.contains("system") || lower.contains("environment") { return "desktopcomputer" }
if lower.contains("config") { return "doc.text" }
if lower.contains("model") || lower.contains("provider") { return "brain" }
if lower.contains("memory") { return "memorychip" }
if lower.contains("session") { return "list.bullet" }
if lower.contains("gateway") || lower.contains("platform") { return "antenna.radiowaves.left.and.right" }
if lower.contains("skill") { return "wrench.and.screwdriver" }
if lower.contains("mcp") { return "cube.box" }
if lower.contains("plugin") { return "puzzlepiece" }
if lower.contains("auth") || lower.contains("credential") { return "key" }
if lower.contains("disk") || lower.contains("storage") { return "internaldrive" }
if lower.contains("update") { return "arrow.triangle.2.circlepath" }
return "circle"
}
private func parseOutput(_ output: String) -> [HealthSection] { private func parseOutput(_ output: String) -> [HealthSection] {
var sections: [HealthSection] = [] var sections: [HealthSection] = []
var currentTitle = "" var currentTitle = ""
@@ -160,20 +333,38 @@ final class HealthViewModel {
} }
} }
/// Capture `hermes dump` output a setup summary used for debugging / support.
/// Does NOT upload anything.
func runDump() {
actionMessage = "Running dump…"
let result = runHermes(["dump"])
diagnosticsOutput = result.output
actionMessage = result.exitCode == 0 ? "Dump captured" : "Dump failed"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
}
/// Upload a debug report via `hermes debug share`. THIS UPLOADS DATA to Nous
/// Research support infrastructure caller must confirm with the user first.
func runDebugShare() {
isSharingDebug = true
actionMessage = "Uploading debug report…"
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["debug", "share"], timeout: 120)
await MainActor.run {
self.isSharingDebug = false
self.diagnosticsOutput = result.output
self.actionMessage = result.exitCode == 0 ? "Upload complete" : "Upload failed"
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
self?.actionMessage = nil
}
}
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process() context.runHermes(arguments)
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)
}
} }
} }
@@ -1,21 +1,46 @@
import SwiftUI import SwiftUI
struct HealthView: View { struct HealthView: View {
@State private var viewModel = HealthViewModel() @State private var viewModel: HealthViewModel
@State private var expandedSection: UUID? @State private var expandedSection: UUID?
@State private var selectedTab = 0 @State private var selectedTab = 0
@State private var showShareConfirm = false
@State private var showDiagnostics = false
init(context: ServerContext) {
_viewModel = State(initialValue: HealthViewModel(context: context))
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
headerBar headerBar
Divider() Divider()
HStack {
Picker("", selection: $selectedTab) { Picker("", selection: $selectedTab) {
Text("Status").tag(0) Text("Status").tag(0)
Text("Diagnostics").tag(1) Text("Diagnostics").tag(1)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(maxWidth: 300) .frame(maxWidth: 300)
Spacer()
Button("Run Dump") {
viewModel.runDump()
showDiagnostics = true
}
.controlSize(.small)
Button("Share Debug Report…") {
showShareConfirm = true
}
.controlSize(.small)
.disabled(viewModel.isSharingDebug)
}
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.horizontal)
if showDiagnostics && !viewModel.diagnosticsOutput.isEmpty {
Divider()
diagnosticsPanel
}
Divider() Divider()
ScrollView { ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections) sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
@@ -23,12 +48,52 @@ struct HealthView: View {
} }
} }
.navigationTitle("Health") .navigationTitle("Health")
.loadingOverlay(
viewModel.isLoading,
label: "Running health checks…",
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
)
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
Button("Upload", role: .destructive) {
viewModel.runDebugShare()
showDiagnostics = true
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL.")
}
}
private var diagnosticsPanel: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Diagnostic Output")
.font(.caption.bold())
.foregroundStyle(.secondary)
Spacer()
Button("Hide") { showDiagnostics = false }
.controlSize(.mini)
}
ScrollView {
Text(viewModel.diagnosticsOutput)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 240)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal)
.padding(.vertical, 8)
} }
// MARK: - Header // MARK: - Header
private var headerBar: some View { private var headerBar: some View {
VStack(spacing: 0) {
HStack(spacing: 16) { HStack(spacing: 16) {
if !viewModel.version.isEmpty { if !viewModel.version.isEmpty {
Text(viewModel.version) Text(viewModel.version)
@@ -59,6 +124,44 @@ struct HealthView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .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 // MARK: - Grid
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
let outputTokens: Int let outputTokens: Int
let cacheReadTokens: Int let cacheReadTokens: Int
let cacheWriteTokens: Int let cacheWriteTokens: Int
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens } let reasoningTokens: Int
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
} }
struct PlatformUsage: Identifiable { struct PlatformUsage: Identifiable {
@@ -46,7 +47,7 @@ struct ToolUsage: Identifiable {
} }
struct NotableSession: Identifiable { struct NotableSession: Identifiable {
var id: String { session.id } var id: String { "\(session.id)-\(label)" }
let label: String let label: String
let value: String let value: String
let session: HermesSession let session: HermesSession
@@ -55,7 +56,14 @@ struct NotableSession: Identifiable {
@Observable @Observable
final class InsightsViewModel { final class InsightsViewModel {
private let dataService = HermesDataService() let context: ServerContext
private let dataService: HermesDataService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
}
var period: InsightsPeriod = .month var period: InsightsPeriod = .month
var isLoading = true var isLoading = true
@@ -69,7 +77,9 @@ final class InsightsViewModel {
var totalOutputTokens = 0 var totalOutputTokens = 0
var totalCacheReadTokens = 0 var totalCacheReadTokens = 0
var totalCacheWriteTokens = 0 var totalCacheWriteTokens = 0
var totalReasoningTokens = 0
var totalTokens = 0 var totalTokens = 0
var totalCost: Double = 0
var activeTime: TimeInterval = 0 var activeTime: TimeInterval = 0
var avgSessionDuration: TimeInterval = 0 var avgSessionDuration: TimeInterval = 0
@@ -82,7 +92,9 @@ final class InsightsViewModel {
func load() async { func load() async {
isLoading = true isLoading = true
let opened = await dataService.open() // refresh() forces a fresh remote snapshot each load. On local it's
// a cheap reopen of the live DB.
let opened = await dataService.refresh()
guard opened else { guard opened else {
isLoading = false isLoading = false
return return
@@ -119,7 +131,9 @@ final class InsightsViewModel {
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens } totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens } totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens } totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
var total: TimeInterval = 0 var total: TimeInterval = 0
var count = 0 var count = 0
@@ -134,21 +148,22 @@ final class InsightsViewModel {
} }
private func computeModelBreakdown() { private func computeModelBreakdown() {
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:] var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
for s in sessions { for s in sessions {
let model = s.model ?? "unknown" let model = s.model ?? "unknown"
var entry = grouped[model, default: (0, 0, 0, 0, 0)] var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
entry.sessions += 1 entry.sessions += 1
entry.input += s.inputTokens entry.input += s.inputTokens
entry.output += s.outputTokens entry.output += s.outputTokens
entry.cacheRead += s.cacheReadTokens entry.cacheRead += s.cacheReadTokens
entry.cacheWrite += s.cacheWriteTokens entry.cacheWrite += s.cacheWriteTokens
entry.reasoning += s.reasoningTokens
grouped[model] = entry grouped[model] = entry
} }
modelUsage = grouped.map { key, val in modelUsage = grouped.map { key, val in
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input, ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
outputTokens: val.output, cacheReadTokens: val.cacheRead, outputTokens: val.output, cacheReadTokens: val.cacheRead,
cacheWriteTokens: val.cacheWrite) cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
}.sorted { $0.totalTokens > $1.totalTokens } }.sorted { $0.totalTokens > $1.totalTokens }
} }
@@ -158,7 +173,7 @@ final class InsightsViewModel {
var entry = grouped[s.source, default: (0, 0, 0)] var entry = grouped[s.source, default: (0, 0, 0)]
entry.sessions += 1 entry.sessions += 1
entry.messages += s.messageCount entry.messages += s.messageCount
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
grouped[s.source] = entry grouped[s.source] = entry
} }
platformUsage = grouped.map { key, val in platformUsage = grouped.map { key, val in
@@ -1,8 +1,14 @@
import SwiftUI import SwiftUI
struct InsightsView: View { struct InsightsView: View {
@State private var viewModel = InsightsViewModel() @State private var viewModel: InsightsViewModel
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: InsightsViewModel(context: context))
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -23,6 +29,9 @@ struct InsightsView: View {
.onChange(of: viewModel.period) { .onChange(of: viewModel.period) {
Task { await viewModel.load() } Task { await viewModel.load() }
} }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
} }
private var periodPicker: some View { private var periodPicker: some View {
@@ -50,7 +59,9 @@ struct InsightsView: View {
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens)) InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens)) InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens)) 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 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: "Active Time", value: formatDuration(viewModel.activeTime))
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration)) 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))) InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
@@ -273,19 +284,12 @@ struct InsightsView: View {
// MARK: - Helpers // MARK: - Helpers
private func platformIcon(_ platform: String) -> String { private func platformIcon(_ platform: String) -> String {
switch platform { KnownPlatforms.icon(for: platform)
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "email": return "envelope"
default: return "bubble.left"
}
} }
private func barColor(for toolName: String) -> Color { private func barColor(for toolName: String) -> Color {
switch toolName { switch toolName {
case "terminal": return .orange case "terminal", "execute_code": return .orange
case "read_file", "search_files": return .green case "read_file", "search_files": return .green
case "write_file", "patch": return .blue case "write_file", "patch": return .blue
case "web_search", "web_extract": return .purple case "web_search", "web_extract": return .purple
@@ -2,24 +2,55 @@ import Foundation
@Observable @Observable
final class LogsViewModel { final class LogsViewModel {
private let logService = HermesLogService() let context: ServerContext
private let logService: HermesLogService
init(context: ServerContext = .local) {
self.context = context
self.logService = HermesLogService(context: context)
}
var entries: [LogEntry] = [] var entries: [LogEntry] = []
var selectedLogFile: LogFile = .errors var selectedLogFile: LogFile = .agent
var filterLevel: LogEntry.LogLevel? var filterLevel: LogEntry.LogLevel?
var selectedComponent: LogComponent = .all
var searchText = "" var searchText = ""
private var pollTimer: Timer? private var pollTimer: Timer?
enum LogFile: String, CaseIterable, Identifiable { enum LogFile: String, CaseIterable, Identifiable {
case agent = "agent.log"
case errors = "errors.log" case errors = "errors.log"
case gateway = "gateway.log" case gateway = "gateway.log"
var id: String { rawValue } var id: String { rawValue }
}
var path: String { private func path(for file: LogFile) -> String {
switch file {
case .agent: return context.paths.agentLog
case .errors: return context.paths.errorsLog
case .gateway: return context.paths.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 { switch self {
case .errors: return HermesPaths.errorsLog case .all: return nil
case .gateway: return HermesPaths.gatewayLog case .gateway: return "gateway"
case .agent: return "agent"
case .tools: return "tools"
case .cli: return "cli"
case .cron: return "cron"
} }
} }
} }
@@ -28,12 +59,16 @@ final class LogsViewModel {
entries.filter { entry in entries.filter { entry in
let levelOk = filterLevel == nil || entry.level == filterLevel let levelOk = filterLevel == nil || entry.level == filterLevel
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText) 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
} }
} }
func load() async { func load() async {
await logService.openLog(path: selectedLogFile.path) await logService.openLog(path: path(for: selectedLogFile))
entries = await logService.readLastLines(count: 500) entries = await logService.readLastLines(count: 500)
await logService.seekToEnd() await logService.seekToEnd()
startPolling() startPolling()
@@ -42,7 +77,7 @@ final class LogsViewModel {
func switchLogFile(_ file: LogFile) async { func switchLogFile(_ file: LogFile) async {
selectedLogFile = file selectedLogFile = file
entries = [] entries = []
await logService.openLog(path: file.path) await logService.openLog(path: path(for: file))
entries = await logService.readLastLines(count: 500) entries = await logService.readLastLines(count: 500)
await logService.seekToEnd() await logService.seekToEnd()
} }
+34 -1
View File
@@ -1,7 +1,12 @@
import SwiftUI import SwiftUI
struct LogsView: View { struct LogsView: View {
@State private var viewModel = LogsViewModel() @State private var viewModel: LogsViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: LogsViewModel(context: context))
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -28,6 +33,13 @@ struct LogsView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(maxWidth: 300) .frame(maxWidth: 300)
Picker("Component", selection: $viewModel.selectedComponent) {
ForEach(LogsViewModel.LogComponent.allCases) { component in
Text(component.rawValue).tag(component)
}
}
.frame(maxWidth: 140)
Spacer() Spacer()
Picker("Level", selection: $viewModel.filterLevel) { Picker("Level", selection: $viewModel.filterLevel) {
@@ -58,6 +70,27 @@ struct LogsView: View {
.font(.caption.monospaced().bold()) .font(.caption.monospaced().bold())
.foregroundStyle(colorForLevel(entry.level)) .foregroundStyle(colorForLevel(entry.level))
.frame(width: 60, alignment: .leading) .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) Text(entry.message)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
@@ -0,0 +1,120 @@
import Foundation
@Observable
final class MCPServerEditorViewModel {
struct KeyValueRow: Identifiable, Equatable {
let id = UUID()
var key: String
var value: String
}
let context: ServerContext
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, context: ServerContext = .local) {
self.server = server
self.context = context
self.fileService = HermesFileService(context: context)
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 {
// Compute success as an immutable so the MainActor.run closure
// captures a value, not a mutable var. Swift 6 rejects
// var-captures across concurrent closures as data races.
let success: Bool = {
var ok = true
switch transport {
case .stdio:
if !service.setMCPServerEnv(name: name, env: envMap) { ok = false }
case .http:
if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false }
}
if !service.updateMCPToolFilters(
name: name,
include: include,
exclude: exclude,
resources: resources,
prompts: prompts
) { ok = false }
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
ok = false
}
return ok
}()
await MainActor.run {
self.isSaving = false
if !success {
self.saveError = "One or more fields could not be written. Check \(self.context.paths.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,239 @@
import Foundation
@Observable
final class MCPServersViewModel {
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
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
let svc = fileService
Task.detached { [weak self] in
// loadMCPServers reads config.yaml + lists mcp-tokens both
// are sync transport calls that block on remote ssh round-trips.
let result = svc.loadMCPServers()
await MainActor.run { [weak self] in
guard let self else { return }
self.servers = result
self.isLoading = false
if let name = self.selectedServerName, !result.contains(where: { $0.name == name }) {
self.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,173 @@
import SwiftUI
struct MCPServersView: View {
@State private var viewModel: MCPServersViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: MCPServersViewModel(context: context))
}
var body: some View {
HSplitView {
serversList
.frame(minWidth: 260, idealWidth: 300)
serverDetail
.frame(minWidth: 500)
}
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
.loadingOverlay(
viewModel.isLoading,
label: "Loading MCP servers…",
isEmpty: viewModel.servers.isEmpty
)
.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))
}
}
@@ -2,13 +2,24 @@ import Foundation
@Observable @Observable
final class MemoryViewModel { final class MemoryViewModel {
private let fileService = HermesFileService() let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
var memoryContent = "" var memoryContent = ""
var userContent = "" var userContent = ""
var memoryProvider = ""
var isEditing = false var isEditing = false
var editingFile: EditTarget = .memory var editingFile: EditTarget = .memory
var editText = "" var editText = ""
var profiles: [String] = []
var activeProfile = ""
var isLoading = false
enum EditTarget { enum EditTarget {
case memory, user case memory, user
@@ -17,9 +28,50 @@ final class MemoryViewModel {
var memoryCharCount: Int { memoryContent.count } var memoryCharCount: Int { memoryContent.count }
var userCharCount: Int { userContent.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() { func load() {
memoryContent = fileService.loadMemory() isLoading = true
userContent = fileService.loadUserProfile() let svc = fileService
let currentProfile = activeProfile
// Sync transport calls would beach-ball the UI on remote dispatch
// off main, then commit results back on MainActor.
Task.detached { [weak self] in
let config = svc.loadConfig()
let profiles = svc.loadMemoryProfiles()
let profile = currentProfile.isEmpty ? config.memoryProfile : currentProfile
let memory = svc.loadMemory(profile: profile)
let user = svc.loadUserProfile(profile: profile)
await MainActor.run { [weak self] in
guard let self else { return }
self.memoryProvider = config.memoryProvider
self.profiles = profiles
self.activeProfile = profile
self.memoryContent = memory
self.userContent = user
self.isLoading = false
}
}
}
func switchProfile(_ profile: String) {
activeProfile = profile
let svc = fileService
Task.detached { [weak self] in
let memory = svc.loadMemory(profile: profile)
let user = svc.loadUserProfile(profile: profile)
await MainActor.run { [weak self] in
self?.memoryContent = memory
self?.userContent = user
}
}
} }
func startEditing(_ target: EditTarget) { func startEditing(_ target: EditTarget) {
@@ -29,15 +81,24 @@ final class MemoryViewModel {
} }
func save() { func save() {
switch editingFile { let svc = fileService
case .memory: let target = editingFile
fileService.saveMemory(editText) let text = editText
memoryContent = editText let profile = activeProfile
case .user: Task.detached { [weak self] in
fileService.saveUserProfile(editText) switch target {
userContent = editText case .memory: svc.saveMemory(text, profile: profile)
case .user: svc.saveUserProfile(text, profile: profile)
}
await MainActor.run { [weak self] in
guard let self else { return }
switch target {
case .memory: self.memoryContent = text
case .user: self.userContent = text
}
self.isEditing = false
}
} }
isEditing = false
} }
func cancelEditing() { func cancelEditing() {
@@ -1,12 +1,46 @@
import SwiftUI import SwiftUI
struct MemoryView: View { struct MemoryView: View {
@State private var viewModel = MemoryViewModel() @State private var viewModel: MemoryViewModel
@Environment(HermesFileWatcher.self) private var fileWatcher @Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: MemoryViewModel(context: context))
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { 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("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user) memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
} }
@@ -14,6 +48,11 @@ struct MemoryView: View {
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
} }
.navigationTitle("Memory") .navigationTitle("Memory")
.loadingOverlay(
viewModel.isLoading,
label: "Loading memory…",
isEmpty: viewModel.memoryContent.isEmpty && viewModel.userContent.isEmpty
)
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { .onChange(of: fileWatcher.lastChangeDate) {
viewModel.load() viewModel.load()
@@ -42,8 +81,7 @@ struct MemoryView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding() .padding()
} else { } else {
Text(markdownAttributed(content)) MarkdownContentView(content: content)
.textSelection(.enabled)
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5)) .background(.quaternary.opacity(0.5))
@@ -64,14 +102,17 @@ struct MemoryView: View {
} }
.padding() .padding()
Divider() Divider()
HSplitView {
TextEditor(text: $viewModel.editText) TextEditor(text: $viewModel.editText)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.padding(8) .padding(8)
} ScrollView {
.frame(minWidth: 600, minHeight: 400) MarkdownContentView(content: viewModel.editText)
} .padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
private func markdownAttributed(_ text: String) -> AttributedString { }
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text) }
}
.frame(minWidth: 800, minHeight: 500)
} }
} }
@@ -0,0 +1,125 @@
import Foundation
import AppKit
import os
/// A personality defined under the `personalities:` block in config.yaml.
/// Each entry may have a free-form `prompt` string plus arbitrary extra fields.
struct HermesPersonality: Identifiable, Sendable, Equatable {
var id: String { name }
let name: String
let prompt: String
}
@Observable
final class PersonalitiesViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "PersonalitiesViewModel")
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
var personalities: [HermesPersonality] = []
var activeName: String = ""
var soulMarkdown: String = ""
var soulPath: String { context.paths.soulMD }
var message: String?
func load() {
let svc = fileService
let ctx = context
let path = soulPath
Task.detached { [weak self] in
let config = svc.loadConfig()
let parsed = Self.parsePersonalitiesBlock(yaml: ctx.readText(ctx.paths.configYAML) ?? "")
let soul = ctx.readText(path) ?? ""
await MainActor.run { [weak self] in
guard let self else { return }
self.activeName = config.personality
self.personalities = parsed
self.soulMarkdown = soul
}
}
}
/// Static form so the detached load can call into it without touching
/// MainActor-isolated state. The instance form below remains for any
/// other callers that need it.
nonisolated private static func parsePersonalitiesBlock(yaml: String) -> [HermesPersonality] {
guard !yaml.isEmpty else { return [] }
let parsed = HermesFileService.parseNestedYAML(yaml)
var nameSet: Set<String> = []
for key in parsed.values.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
return nameSet.sorted().map { name in
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
}
}
/// Parse the `personalities:` section of config.yaml using the nested parser.
/// Each personality is a top-level key under `personalities`, optionally with
/// a `prompt:` child.
private func parsePersonalitiesBlock() -> [HermesPersonality] {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
let parsed = HermesFileService.parseNestedYAML(yaml)
// Find all keys "personalities.<name>[.subkey]"
var nameSet: Set<String> = []
for key in parsed.values.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
for key in parsed.lists.keys where key.hasPrefix("personalities.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
if parts.count >= 2 { nameSet.insert(String(parts[1])) }
}
return nameSet.sorted().map { name in
let prompt = parsed.values["personalities.\(name).prompt"] ?? ""
return HermesPersonality(name: name, prompt: HermesFileService.stripYAMLQuotes(prompt))
}
}
func setActive(_ name: String) {
let result = runHermes(["config", "set", "display.personality", name])
if result.exitCode == 0 {
activeName = name
message = "Active personality set to \(name)"
} else {
logger.warning("Failed to set personality: \(result.output)")
message = "Failed to set personality"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func saveSOUL(_ content: String) {
if context.writeText(soulPath, content: content) {
soulMarkdown = content
message = "SOUL.md saved"
} else {
logger.error("Failed to write SOUL.md to \(self.context.displayName)")
message = "Save failed"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.message = nil
}
}
func openConfigInEditor() {
context.openInLocalEditor(context.paths.configYAML)
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
context.runHermes(arguments)
}
}
@@ -0,0 +1,138 @@
import SwiftUI
struct PersonalitiesView: View {
@State private var viewModel: PersonalitiesViewModel
@State private var soulDraft = ""
@State private var editingSOUL = false
init(context: ServerContext) {
_viewModel = State(initialValue: PersonalitiesViewModel(context: context))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
activeSection
listSection
soulSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Personalities")
.onAppear {
viewModel.load()
soulDraft = viewModel.soulMarkdown
}
}
private var header: some View {
HStack {
if let msg = viewModel.message {
Label(msg, systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
Spacer()
Button("Edit config.yaml") { viewModel.openConfigInEditor() }
.controlSize(.small)
Button("Reload") { viewModel.load(); soulDraft = viewModel.soulMarkdown }
.controlSize(.small)
}
}
private var activeSection: some View {
SettingsSection(title: "Active Personality", icon: "theatermasks.fill") {
if viewModel.personalities.isEmpty {
ReadOnlyRow(label: "Current", value: viewModel.activeName.isEmpty ? "default" : viewModel.activeName)
ReadOnlyRow(label: "Defined", value: "None in config.yaml — add under `personalities:` to customize.")
} else {
PickerRow(label: "Active", selection: viewModel.activeName, options: viewModel.personalities.map(\.name)) { viewModel.setActive($0) }
}
}
}
@ViewBuilder
private var listSection: some View {
if !viewModel.personalities.isEmpty {
SettingsSection(title: "Defined Personalities", icon: "list.bullet") {
ForEach(viewModel.personalities) { personality in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(personality.name)
.font(.system(.body, design: .monospaced, weight: .medium))
if personality.name == viewModel.activeName {
Text("active")
.font(.caption2.bold())
.foregroundStyle(.green)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.green.opacity(0.15))
.clipShape(Capsule())
}
Spacer()
}
if !personality.prompt.isEmpty {
Text(personality.prompt)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(6)
.textSelection(.enabled)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
}
}
}
}
private var soulSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("SOUL.md", systemImage: "sparkles")
.font(.headline)
Spacer()
if editingSOUL {
Button("Cancel") {
editingSOUL = false
soulDraft = viewModel.soulMarkdown
}
.controlSize(.small)
Button("Save") {
viewModel.saveSOUL(soulDraft)
editingSOUL = false
}
.controlSize(.small)
.keyboardShortcut("s", modifiers: .command)
} else {
Button("Edit") { editingSOUL = true }
.controlSize(.small)
}
}
Text("SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context.")
.font(.caption)
.foregroundStyle(.secondary)
if editingSOUL {
TextEditor(text: $soulDraft)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 220)
.padding(6)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Text(viewModel.soulMarkdown.isEmpty ? "(empty)" : viewModel.soulMarkdown)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(viewModel.soulMarkdown.isEmpty ? .secondary : .primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import os
/// Discord setup. Bot token + user IDs in `.env`, behavior knobs in `discord.*`.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord
@Observable
@MainActor
final class DiscordSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) { self.context = context }
var botToken: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var homeChannelName: String = ""
var allowBots: String = "none" // "none" | "mentions" | "all"
var replyToMode: String = "first" // "off" | "first" | "all"
// config.yaml these mirror the existing `HermesConfig.discord` block so we
// stay consistent with whatever the Settings UI shows.
var requireMention: Bool = true
var freeResponseChannels: String = ""
var autoThread: Bool = true
var reactions: Bool = true
var message: String?
let allowBotsOptions = ["none", "mentions", "all"]
let replyToModeOptions = ["off", "first", "all"]
func load() {
let env = HermesEnvService(context: context).load()
botToken = env["DISCORD_BOT_TOKEN"] ?? ""
allowedUsers = env["DISCORD_ALLOWED_USERS"] ?? ""
homeChannel = env["DISCORD_HOME_CHANNEL"] ?? ""
homeChannelName = env["DISCORD_HOME_CHANNEL_NAME"] ?? ""
allowBots = env["DISCORD_ALLOW_BOTS"] ?? "none"
replyToMode = env["DISCORD_REPLY_TO_MODE"] ?? "first"
let cfg = HermesFileService(context: context).loadConfig().discord
requireMention = cfg.requireMention
freeResponseChannels = cfg.freeResponseChannels
autoThread = cfg.autoThread
reactions = cfg.reactions
}
func save() {
let envPairs: [String: String] = [
"DISCORD_BOT_TOKEN": botToken,
"DISCORD_ALLOWED_USERS": allowedUsers,
"DISCORD_HOME_CHANNEL": homeChannel,
"DISCORD_HOME_CHANNEL_NAME": homeChannelName,
"DISCORD_ALLOW_BOTS": allowBots == "none" ? "" : allowBots, // default is "none", don't persist
"DISCORD_REPLY_TO_MODE": replyToMode == "first" ? "" : replyToMode
]
let configKV: [String: String] = [
"discord.require_mention": PlatformSetupHelpers.envBool(requireMention),
"discord.free_response_channels": freeResponseChannels,
"discord.auto_thread": PlatformSetupHelpers.envBool(autoThread),
"discord.reactions": PlatformSetupHelpers.envBool(reactions)
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,86 @@
import Foundation
/// Email setup. IMAP/SMTP with app passwords no OAuth.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email
@Observable
@MainActor
final class EmailSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
}
var address: String = ""
var password: String = ""
var imapHost: String = ""
var smtpHost: String = ""
var imapPort: String = "993"
var smtpPort: String = "587"
var pollInterval: String = "15"
var allowedUsers: String = ""
var homeAddress: String = ""
var allowAllUsers: Bool = false
var skipAttachments: Bool = false
var message: String?
/// Common provider presets so users don't have to look up IMAP/SMTP servers.
struct Preset {
let name: String
let imap: String
let smtp: String
}
let presets: [Preset] = [
Preset(name: "Gmail", imap: "imap.gmail.com", smtp: "smtp.gmail.com"),
Preset(name: "Outlook", imap: "outlook.office365.com", smtp: "smtp.office365.com"),
Preset(name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com"),
Preset(name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com"),
Preset(name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com")
]
func load() {
let env = HermesEnvService(context: context).load()
address = env["EMAIL_ADDRESS"] ?? ""
password = env["EMAIL_PASSWORD"] ?? ""
imapHost = env["EMAIL_IMAP_HOST"] ?? ""
smtpHost = env["EMAIL_SMTP_HOST"] ?? ""
imapPort = env["EMAIL_IMAP_PORT"] ?? "993"
smtpPort = env["EMAIL_SMTP_PORT"] ?? "587"
pollInterval = env["EMAIL_POLL_INTERVAL"] ?? "15"
allowedUsers = env["EMAIL_ALLOWED_USERS"] ?? ""
homeAddress = env["EMAIL_HOME_ADDRESS"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["EMAIL_ALLOW_ALL_USERS"])
// skip_attachments lives in config.yaml.
let yaml = context.readText(context.paths.configYAML) ?? ""
let parsed = HermesFileService.parseNestedYAML(yaml)
skipAttachments = (parsed.values["platforms.email.skip_attachments"] ?? "false") == "true"
}
func applyPreset(_ preset: Preset) {
imapHost = preset.imap
smtpHost = preset.smtp
}
func save() {
let envPairs: [String: String] = [
"EMAIL_ADDRESS": address,
"EMAIL_PASSWORD": password,
"EMAIL_IMAP_HOST": imapHost,
"EMAIL_SMTP_HOST": smtpHost,
"EMAIL_IMAP_PORT": imapPort,
"EMAIL_SMTP_PORT": smtpPort,
"EMAIL_POLL_INTERVAL": pollInterval,
"EMAIL_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
"EMAIL_HOME_ADDRESS": homeAddress,
"EMAIL_ALLOW_ALL_USERS": allowAllUsers ? "true" : ""
]
let configKV: [String: String] = [
"platforms.email.skip_attachments": PlatformSetupHelpers.envBool(skipAttachments)
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,50 @@
import Foundation
/// Feishu/Lark setup. Choose domain (feishu = China, lark = international).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu
@Observable
@MainActor
final class FeishuSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) { self.context = context }
var appID: String = ""
var appSecret: String = ""
var domain: String = "lark"
var encryptKey: String = ""
var verificationToken: String = ""
var allowedUsers: String = ""
var connectionMode: String = "websocket" // "websocket" | "webhook"
var message: String?
let domainOptions = ["feishu", "lark"]
let connectionOptions = ["websocket", "webhook"]
func load() {
let env = HermesEnvService(context: context).load()
appID = env["FEISHU_APP_ID"] ?? ""
appSecret = env["FEISHU_APP_SECRET"] ?? ""
domain = env["FEISHU_DOMAIN"] ?? "lark"
encryptKey = env["FEISHU_ENCRYPT_KEY"] ?? ""
verificationToken = env["FEISHU_VERIFICATION_TOKEN"] ?? ""
allowedUsers = env["FEISHU_ALLOWED_USERS"] ?? ""
connectionMode = env["FEISHU_CONNECTION_MODE"] ?? "websocket"
}
func save() {
let envPairs: [String: String] = [
"FEISHU_APP_ID": appID,
"FEISHU_APP_SECRET": appSecret,
"FEISHU_DOMAIN": domain,
"FEISHU_ENCRYPT_KEY": encryptKey,
"FEISHU_VERIFICATION_TOKEN": verificationToken,
"FEISHU_ALLOWED_USERS": allowedUsers,
"FEISHU_CONNECTION_MODE": connectionMode == "websocket" ? "" : connectionMode
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,73 @@
import Foundation
import AppKit
/// Home Assistant setup. Long-lived access token in `.env`, scalar filters via
/// `hermes config set` under `platforms.homeassistant.extra.*`.
///
/// **List fields** (`watch_domains`, `watch_entities`, `ignore_entities`) are
/// NOT editable in the form. `hermes config set` stores array arguments as
/// quoted strings instead of YAML lists, which hermes would then reject as
/// invalid. Users edit these directly in config.yaml the view shows the
/// current values (read-only) and an "Edit in config.yaml" button that opens
/// the file.
///
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/homeassistant
@Observable
@MainActor
final class HomeAssistantSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) {
self.context = context
}
var url: String = "http://homeassistant.local:8123"
var token: String = ""
// Scalar filters writable via hermes config set.
var watchAll: Bool = false
var cooldownSeconds: Int = 30
// List filters read-only; user must edit config.yaml manually.
var watchDomains: [String] = []
var watchEntities: [String] = []
var ignoreEntities: [String] = []
var message: String?
func load() {
let env = HermesEnvService(context: context).load()
url = env["HASS_URL"] ?? "http://homeassistant.local:8123"
token = env["HASS_TOKEN"] ?? ""
let cfg = HermesFileService(context: context).loadConfig().homeAssistant
watchAll = cfg.watchAll
cooldownSeconds = cfg.cooldownSeconds
watchDomains = cfg.watchDomains
watchEntities = cfg.watchEntities
ignoreEntities = cfg.ignoreEntities
}
func save() {
let envPairs: [String: String] = [
"HASS_URL": url,
"HASS_TOKEN": token
]
// Only scalar config values lists are skipped intentionally; see
// file header comment for rationale.
let configKV: [String: String] = [
"platforms.homeassistant.extra.watch_all": PlatformSetupHelpers.envBool(watchAll),
"platforms.homeassistant.extra.cooldown_seconds": String(cooldownSeconds)
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
/// Open config.yaml in the user's default editor so they can manually edit
/// the list-valued filter fields.
func openConfigForLists() {
context.openInLocalEditor(context.paths.configYAML)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac
/// that's always on, with an Apple ID signed into Messages.app.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/bluebubbles
@Observable
@MainActor
final class IMessageSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) { self.context = context }
var serverURL: String = ""
var password: String = ""
var webhookHost: String = "127.0.0.1"
var webhookPort: String = "8645"
var webhookPath: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var allowAllUsers: Bool = false
var sendReadReceipts: Bool = false
var message: String?
func load() {
let env = HermesEnvService(context: context).load()
serverURL = env["BLUEBUBBLES_SERVER_URL"] ?? ""
password = env["BLUEBUBBLES_PASSWORD"] ?? ""
webhookHost = env["BLUEBUBBLES_WEBHOOK_HOST"] ?? "127.0.0.1"
webhookPort = env["BLUEBUBBLES_WEBHOOK_PORT"] ?? "8645"
webhookPath = env["BLUEBUBBLES_WEBHOOK_PATH"] ?? ""
allowedUsers = env["BLUEBUBBLES_ALLOWED_USERS"] ?? ""
homeChannel = env["BLUEBUBBLES_HOME_CHANNEL"] ?? ""
allowAllUsers = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_ALLOW_ALL_USERS"])
sendReadReceipts = PlatformSetupHelpers.parseEnvBool(env["BLUEBUBBLES_SEND_READ_RECEIPTS"])
}
func save() {
let envPairs: [String: String] = [
"BLUEBUBBLES_SERVER_URL": serverURL,
"BLUEBUBBLES_PASSWORD": password,
"BLUEBUBBLES_WEBHOOK_HOST": webhookHost,
"BLUEBUBBLES_WEBHOOK_PORT": webhookPort,
"BLUEBUBBLES_WEBHOOK_PATH": webhookPath,
"BLUEBUBBLES_ALLOWED_USERS": allowAllUsers ? "" : allowedUsers,
"BLUEBUBBLES_HOME_CHANNEL": homeChannel,
"BLUEBUBBLES_ALLOW_ALL_USERS": allowAllUsers ? "true" : "",
"BLUEBUBBLES_SEND_READ_RECEIPTS": sendReadReceipts ? "true" : ""
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Matrix setup. Supports both access-token and password auth. No SSO.
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix
@Observable
@MainActor
final class MatrixSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) { self.context = context }
var homeserver: String = ""
var accessToken: String = "" // preferred
var userID: String = ""
var password: String = "" // alternative to accessToken
var allowedUsers: String = ""
var homeRoom: String = ""
var recoveryKey: String = ""
var encryption: Bool = false
// config.yaml
var requireMention: Bool = true
var autoThread: Bool = true
var dmMentionThreads: Bool = false
var message: String?
func load() {
let env = HermesEnvService(context: context).load()
homeserver = env["MATRIX_HOMESERVER"] ?? ""
accessToken = env["MATRIX_ACCESS_TOKEN"] ?? ""
userID = env["MATRIX_USER_ID"] ?? ""
password = env["MATRIX_PASSWORD"] ?? ""
allowedUsers = env["MATRIX_ALLOWED_USERS"] ?? ""
homeRoom = env["MATRIX_HOME_ROOM"] ?? ""
recoveryKey = env["MATRIX_RECOVERY_KEY"] ?? ""
encryption = PlatformSetupHelpers.parseEnvBool(env["MATRIX_ENCRYPTION"])
let cfg = HermesFileService(context: context).loadConfig().matrix
requireMention = cfg.requireMention
autoThread = cfg.autoThread
dmMentionThreads = cfg.dmMentionThreads
}
func save() {
let envPairs: [String: String] = [
"MATRIX_HOMESERVER": homeserver,
"MATRIX_ACCESS_TOKEN": accessToken,
"MATRIX_USER_ID": userID,
"MATRIX_PASSWORD": password,
"MATRIX_ALLOWED_USERS": allowedUsers,
"MATRIX_HOME_ROOM": homeRoom,
"MATRIX_RECOVERY_KEY": recoveryKey,
"MATRIX_ENCRYPTION": encryption ? "true" : ""
]
let configKV: [String: String] = [
"matrix.require_mention": PlatformSetupHelpers.envBool(requireMention),
"matrix.auto_thread": PlatformSetupHelpers.envBool(autoThread),
"matrix.dm_mention_threads": PlatformSetupHelpers.envBool(dmMentionThreads)
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: configKV)
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}
@@ -0,0 +1,51 @@
import Foundation
/// Mattermost setup. Server URL + personal access token (or bot token).
/// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost
@Observable
@MainActor
final class MattermostSetupViewModel {
let context: ServerContext
init(context: ServerContext = .local) { self.context = context }
var serverURL: String = ""
var token: String = ""
var allowedUsers: String = ""
var homeChannel: String = ""
var freeResponseChannels: String = ""
var replyMode: String = "off"
var requireMention: Bool = true
var message: String?
let replyModeOptions = ["off", "thread"]
func load() {
let env = HermesEnvService(context: context).load()
serverURL = env["MATTERMOST_URL"] ?? ""
token = env["MATTERMOST_TOKEN"] ?? ""
allowedUsers = env["MATTERMOST_ALLOWED_USERS"] ?? ""
homeChannel = env["MATTERMOST_HOME_CHANNEL"] ?? ""
freeResponseChannels = env["MATTERMOST_FREE_RESPONSE_CHANNELS"] ?? ""
replyMode = env["MATTERMOST_REPLY_MODE"] ?? "off"
let cfg = HermesFileService(context: context).loadConfig().mattermost
requireMention = cfg.requireMention
}
func save() {
let envPairs: [String: String] = [
"MATTERMOST_URL": serverURL,
"MATTERMOST_TOKEN": token,
"MATTERMOST_ALLOWED_USERS": allowedUsers,
"MATTERMOST_HOME_CHANNEL": homeChannel,
"MATTERMOST_FREE_RESPONSE_CHANNELS": freeResponseChannels,
"MATTERMOST_REPLY_MODE": replyMode == "off" ? "" : replyMode,
"MATTERMOST_REQUIRE_MENTION": PlatformSetupHelpers.envBool(requireMention)
]
message = PlatformSetupHelpers.saveForm(context: context, envPairs: envPairs, configKV: [:])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.message = nil
}
}
}

Some files were not shown because too many files have changed in this diff Show More