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>
This commit is contained in:
Alan Wizemann
2026-04-08 22:48:53 -04:00
parent 3acf95a824
commit 790efb585b
4 changed files with 152 additions and 25 deletions
@@ -235,6 +235,10 @@ struct HermesFileService: Sendable {
// MARK: - Hermes Process // MARK: - Hermes Process
func isHermesRunning() -> Bool { func isHermesRunning() -> Bool {
hermesPID() != nil
}
func hermesPID() -> pid_t? {
let pipe = Pipe() let pipe = Pipe()
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
@@ -245,12 +249,21 @@ struct HermesFileService: Sendable {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
return !data.isEmpty let output = String(data: data, encoding: .utf8) ?? ""
guard let firstLine = output.components(separatedBy: "\n").first(where: { !$0.isEmpty }),
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
return pid
} catch { } catch {
return false return nil
} }
} }
@discardableResult
func stopHermes() -> Bool {
guard let pid = hermesPID() else { return false }
return kill(pid, SIGTERM) == 0
}
// MARK: - File I/O // MARK: - File I/O
private func readFile(_ path: String) -> String? { private func readFile(_ path: String) -> String? {
@@ -22,6 +22,8 @@ struct HealthSection: Identifiable {
@Observable @Observable
final class HealthViewModel { final class HealthViewModel {
private let fileService = HermesFileService()
var version = "" var version = ""
var updateInfo = "" var updateInfo = ""
var hasUpdate = false var hasUpdate = false
@@ -31,9 +33,13 @@ 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?
func load() { func load() {
isLoading = true isLoading = true
refreshProcessStatus()
loadVersion() loadVersion()
let statusOutput = runHermes(["status"]).output let statusOutput = runHermes(["status"]).output
statusSections = parseOutput(statusOutput) statusSections = parseOutput(statusOutput)
@@ -43,6 +49,41 @@ final class HealthViewModel {
isLoading = false isLoading = false
} }
func refreshProcessStatus() {
hermesPID = fileService.hermesPID()
hermesRunning = hermesPID != nil
}
func stopHermes() {
fileService.stopHermes()
actionMessage = "Stop signal sent"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.refreshProcessStatus()
self?.actionMessage = nil
}
}
func startHermes() {
runHermes(["gateway", "start"])
actionMessage = "Start requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.refreshProcessStatus()
self?.actionMessage = nil
}
}
func restartHermes() {
fileService.stopHermes()
actionMessage = "Restarting..."
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.runHermes(["gateway", "start"])
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.refreshProcessStatus()
self?.actionMessage = nil
}
}
}
private func loadVersion() { private func loadVersion() {
let output = runHermes(["version"]).output let output = runHermes(["version"]).output
let lines = output.components(separatedBy: "\n") let lines = output.components(separatedBy: "\n")
@@ -29,6 +29,7 @@ struct HealthView: View {
// 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 +60,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
+34
View File
@@ -54,6 +54,33 @@ final class MenuBarStatus {
timer = nil timer = nil
} }
func startHermes() {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = ["gateway", "start"]
process.standardOutput = Pipe()
process.standardError = Pipe()
try? process.run()
process.waitUntilExit()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.refresh()
}
}
func stopHermes() {
fileService.stopHermes()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.refresh()
}
}
func restartHermes() {
fileService.stopHermes()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.startHermes()
}
}
private func refresh() { private func refresh() {
hermesRunning = fileService.isHermesRunning() hermesRunning = fileService.isHermesRunning()
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
@@ -69,6 +96,13 @@ struct MenuBarMenu: View {
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle") Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle") Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
Divider() Divider()
Button("Start Hermes") { status.startHermes() }
.disabled(status.hermesRunning)
Button("Stop Hermes") { status.stopHermes() }
.disabled(!status.hermesRunning)
Button("Restart Hermes") { status.restartHermes() }
.disabled(!status.hermesRunning)
Divider()
Button("Open Dashboard") { Button("Open Dashboard") {
coordinator.selectedSection = .dashboard coordinator.selectedSection = .dashboard
NSApplication.shared.activate() NSApplication.shared.activate()