Files
scarf/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift
T
Alan Wizemann 19b4ba9995 Merge branch 'main' into scarf-mobile-development (v2.3.0)
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:23 +02:00

670 lines
28 KiB
Swift

import Foundation
import ScarfCore
#if canImport(AppKit)
import AppKit
#endif
import os
/// Observed state of the local `hermes dashboard` web UI (introduced in
/// Hermes v0.10.x). `port` defaults to 9119 the CLI's default and the only
/// value Scarf launches with today.
struct WebDashboardStatus: Sendable, Equatable {
var running: Bool
var port: Int
/// True while a start/stop transition is in flight so the UI can disable
/// buttons and show a spinner.
var busy: Bool
static let defaultPort = 9119
static let unknown = WebDashboardStatus(running: false, port: defaultPort, busy: false)
}
struct HealthCheck: Identifiable {
let id = UUID()
let label: String
let status: CheckStatus
let detail: String?
enum CheckStatus {
case ok
case warning
case error
}
}
struct HealthSection: Identifiable {
let id = UUID()
let title: String
let icon: String
let checks: [HealthCheck]
}
@Observable
final class HealthViewModel {
let context: ServerContext
private let fileService: HermesFileService
private let subscriptionService: NousSubscriptionService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
self.subscriptionService = NousSubscriptionService(context: context)
}
var version = ""
var updateInfo = ""
var hasUpdate = false
var statusSections: [HealthSection] = []
var doctorSections: [HealthSection] = []
var issueCount = 0
var warningCount = 0
var okCount = 0
var isLoading = false
var hermesRunning = false
var hermesPID: pid_t?
var actionMessage: String?
/// Text output from `hermes dump` / `hermes debug share`. Shown in an expandable panel.
var diagnosticsOutput: String = ""
var isSharingDebug = false
/// Liveness + control state for `hermes dashboard` (local web UI). The
/// section in `HealthView` is hidden for remote contexts the dashboard
/// binds 127.0.0.1 by default and remote probing / tunneling is out of
/// scope for v1.
var dashboardStatus: WebDashboardStatus = .unknown
/// Our own spawned subprocess, if the user hit "Launch Dashboard" from
/// Scarf. Nil when the dashboard was started externally (we still detect
/// it via the probe but can't terminate it cleanly via `Process.terminate`).
private var dashboardProcess: Process?
/// Background polling loop; started in `startDashboardMonitoring()` and
/// cancelled on view disappear.
private var dashboardProbeTask: Task<Void, Never>?
func load() {
isLoading = true
let ctx = context
let svc = fileService
let subSvc = subscriptionService
// Health runs four sync transport-mediated commands plus a process
// probe that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
// the whole load.
Task.detached { [weak self] in
let pid = svc.hermesPID()
let versionOutput = ctx.runHermes(["version"]).output
let statusOutput = ctx.runHermes(["status"]).output
let doctorOutput = ctx.runHermes(["doctor"]).output
let subscription = subSvc.loadState()
let config = svc.loadConfig()
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)
+ [Self.toolGatewaySection(subscription: subscription, config: config)]
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
}
}
}
/// Synthesize a Tool Gateway health section from the subscription state +
/// `platform_toolsets` table. Runs alongside the other status sections so
/// the user sees at a glance whether their Nous Portal subscription is
/// wired up.
///
/// This is distinct from the "Messaging Gateway" (inbound Slack/Discord/
/// requests) the two are unrelated systems that unfortunately share the
/// "gateway" name in Hermes's CLI output.
///
/// `nonisolated` so `load()` can call it from `Task.detached` alongside
/// `parseOutputStatic` without hopping back to MainActor.
nonisolated private static func toolGatewaySection(subscription: NousSubscriptionState, config: HermesConfig) -> HealthSection {
var checks: [HealthCheck] = []
let subscriptionCheck: HealthCheck = {
if subscription.subscribed {
return HealthCheck(
label: "Nous Portal subscription active",
status: .ok,
detail: "Tool requests route through the Nous Portal gateway."
)
}
if subscription.present {
return HealthCheck(
label: "Signed in, but Nous isn't the active provider",
status: .warning,
detail: "Open Settings → General and pick Nous Portal to route tools through the gateway."
)
}
return HealthCheck(
label: "Not subscribed",
status: .warning,
detail: "Run `hermes auth` and pick Nous Portal to enable subscription-gated tools."
)
}()
checks.append(subscriptionCheck)
if !config.platformToolsets.isEmpty {
let platforms = config.platformToolsets.keys.sorted()
for platform in platforms {
let toolsets = config.platformToolsets[platform] ?? []
checks.append(HealthCheck(
label: "\(platform): \(toolsets.count) toolset\(toolsets.count == 1 ? "" : "s")",
status: .ok,
detail: toolsets.joined(separator: ", ")
))
}
}
let auxOnNous = [
("vision", config.auxiliary.vision.provider),
("web_extract", config.auxiliary.webExtract.provider),
("compression", config.auxiliary.compression.provider),
("session_search", config.auxiliary.sessionSearch.provider),
("skills_hub", config.auxiliary.skillsHub.provider),
("approval", config.auxiliary.approval.provider),
("mcp", config.auxiliary.mcp.provider),
("flush_memories", config.auxiliary.flushMemories.provider),
].filter { $0.1 == "nous" }.map(\.0)
if !auxOnNous.isEmpty {
checks.append(HealthCheck(
label: "Auxiliary tasks routed through Nous",
status: subscription.subscribed ? .ok : .warning,
detail: auxOnNous.joined(separator: ", ")
))
}
return HealthSection(
title: "Tool Gateway",
icon: "arrow.triangle.branch",
checks: checks
)
}
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() {
let output = runHermes(["version"]).output
let lines = output.components(separatedBy: "\n")
version = lines.first ?? ""
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
hasUpdate = true
} else {
updateInfo = ""
hasUpdate = false
}
}
/// 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] {
var sections: [HealthSection] = []
var currentTitle = ""
var currentChecks: [HealthCheck] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("") {
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("") {
let text = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "", with: "")
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("Error:") {
if !currentChecks.isEmpty {
let last = currentChecks.removeLast()
let extra = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
}
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
let parts = trimmed.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let val = parts[1].trimmingCharacters(in: .whitespaces)
if !key.isEmpty && key.count < 30 {
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
}
}
}
}
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
return sections
}
private func splitCheck(_ text: String) -> (String, String?) {
if let parenStart = text.firstIndex(of: "(") {
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> String {
switch title {
case "Environment": return "gearshape.2"
case "API Keys": return "key"
case "Auth Providers": return "person.badge.key"
case "API-Key Providers": return "key.horizontal"
case "Terminal Backend": return "terminal"
case "Messaging Platforms": return "bubble.left.and.bubble.right"
case "Gateway Service": return "antenna.radiowaves.left.and.right"
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
case "Sessions": return "text.bubble"
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
case "Required Packages": return "shippingbox"
case "Configuration Files": return "doc.text"
case "Directory Structure": return "folder"
case "External Tools": return "wrench"
case "API Connectivity": return "wifi"
case "Submodules": return "arrow.triangle.branch"
case "Tool Availability": return "wrench.and.screwdriver"
case "Skills Hub": return "lightbulb"
case "Honcho Memory": return "brain"
default: return "circle"
}
}
/// 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) {
context.runHermes(arguments)
}
// MARK: - Web Dashboard (`hermes dashboard`)
/// Called from `HealthView.onAppear`. Starts a background loop that
/// probes `http://127.0.0.1:<port>/api/status` every 3s and keeps
/// `dashboardStatus.running` in sync with reality whether we launched
/// the dashboard or the user did via terminal. No-op on remote contexts.
func startDashboardMonitoring() {
guard !context.isRemote else { return }
dashboardProbeTask?.cancel()
let port = dashboardStatus.port
dashboardProbeTask = Task { [weak self] in
while !Task.isCancelled {
let running = await Self.probeDashboard(port: port)
await MainActor.run { [weak self] in
guard let self else { return }
// Preserve `busy` so the button stays disabled during an
// in-flight start/stop; only toggle the `running` bit.
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: self.dashboardStatus.busy
)
// Reap our spawned process if it exited externally.
if !running, let p = self.dashboardProcess, !p.isRunning {
self.dashboardProcess = nil
}
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stopDashboardMonitoring() {
dashboardProbeTask?.cancel()
dashboardProbeTask = nil
}
/// Launch `hermes dashboard --no-open --port 9119` detached. We pass
/// `--no-open` so Hermes doesn't try to open its own browser tab Scarf
/// opens the URL after the probe confirms the server is listening, which
/// avoids the "Safari tab loads faster than uvicorn binds the port" race.
func launchDashboard() {
guard !context.isRemote else { return }
guard !dashboardStatus.running, !dashboardStatus.busy else { return }
guard let binary = fileService.hermesBinaryPath() else {
actionMessage = "hermes binary not found"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
return
}
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Starting dashboard…"
let port = dashboardStatus.port
let proc = Process()
proc.executableURL = URL(fileURLWithPath: binary)
proc.arguments = ["dashboard", "--no-open", "--port", String(port)]
proc.environment = HermesFileService.enrichedEnvironment()
// Discard stdout/stderr we rely on the HTTP probe for liveness and
// don't want a growing pipe buffer to block the subprocess.
proc.standardOutput = FileHandle.nullDevice
proc.standardError = FileHandle.nullDevice
do {
try proc.run()
dashboardProcess = proc
Task { [weak self] in
// Give uvicorn up to ~6 seconds to bind the port, probing
// every 300ms. First 200 response opens the browser.
for _ in 0..<20 {
if await Self.probeDashboard(port: port) {
if let url = URL(string: "http://127.0.0.1:\(port)") {
await MainActor.run {
NSWorkspace.shared.open(url)
}
}
break
}
try? await Task.sleep(nanoseconds: 300_000_000)
}
await MainActor.run { [weak self] in
guard let self else { return }
self.dashboardStatus = WebDashboardStatus(
running: self.dashboardStatus.running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
} catch {
Self.dashboardLogger.error("Failed to spawn hermes dashboard: \(error.localizedDescription, privacy: .public)")
dashboardProcess = nil
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: false
)
actionMessage = "Failed to start: \(error.localizedDescription)"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.actionMessage = nil
}
}
}
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
/// so the Stop button works regardless of who launched it.
func stopDashboard() {
guard !context.isRemote else { return }
dashboardStatus = WebDashboardStatus(
running: dashboardStatus.running,
port: dashboardStatus.port,
busy: true
)
actionMessage = "Stopping dashboard…"
if let proc = dashboardProcess, proc.isRunning {
proc.terminate()
dashboardProcess = nil
} else {
// External instance best-effort pkill.
let kill = Process()
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
kill.arguments = ["-f", "hermes dashboard"]
_ = try? kill.run()
kill.waitUntilExit()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self else { return }
Task {
let running = await Self.probeDashboard(port: self.dashboardStatus.port)
await MainActor.run {
self.dashboardStatus = WebDashboardStatus(
running: running,
port: self.dashboardStatus.port,
busy: false
)
self.actionMessage = nil
}
}
}
}
/// Open the dashboard in the default browser. Safe to call only when the
/// probe reports `running: true` UI gates the button on that.
func openDashboardInBrowser() {
guard let url = URL(string: "http://127.0.0.1:\(dashboardStatus.port)") else { return }
NSWorkspace.shared.open(url)
}
/// HEAD-shaped GET against `/api/status`. Returns true on any 2xx response.
/// `/api/status` is whitelisted in `_PUBLIC_API_PATHS` in Hermes's
/// `web_server.py` no token required, so a bare GET works.
///
/// `nonisolated` + `async` so the polling loop can call it without
/// bouncing through MainActor on every tick.
nonisolated private static func probeDashboard(port: Int) async -> Bool {
guard let url = URL(string: "http://127.0.0.1:\(port)/api/status") else { return false }
var request = URLRequest(url: url)
request.timeoutInterval = 0.5
request.httpMethod = "GET"
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 0.5
config.timeoutIntervalForResource = 1.0
let session = URLSession(configuration: config)
defer { session.invalidateAndCancel() }
do {
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse {
return (200..<300).contains(http.statusCode)
}
return false
} catch {
return false
}
}
nonisolated private static let dashboardLogger = Logger(subsystem: "com.scarf", category: "WebDashboard")
}