Resilience: LaunchAgent restarts BroV on crash; crashed Claude Code/Codex sessions get a resume button (optional auto); login and waiting status in the Claude/ChatGPT pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
// MARK: - Keeping BroV and the agents alive
|
||||
//
|
||||
// 1. BroVLaunchAgent — a user LaunchAgent restarts BroV if it crashes (KeepAlive on a
|
||||
// non-zero exit) and starts it at login. Quitting from the menu (exit 0) stays quit.
|
||||
// 2. AgentWatch — the hook relay reports the claude/codex process of each session. When
|
||||
// that process disappears mid-turn (working, thinking, waiting for us) without a
|
||||
// SessionEnd, the session is marked "упала"; one click (or the auto option) reopens
|
||||
// it in Terminal with `claude --resume <id>` / `codex resume <id>`.
|
||||
// 3. AgentAuth — `claude auth status` / `codex login status` every 5 minutes, plus auth
|
||||
// errors seen in the chat or in hook messages, drive the "нужен вход" status.
|
||||
|
||||
// MARK: - LaunchAgent for BroV
|
||||
|
||||
enum BroVLaunchAgent {
|
||||
static let label = "local.maksar.brov"
|
||||
static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||
}
|
||||
static var installedAppPath: String {
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications/BroV.app").path
|
||||
}
|
||||
static let enabledKey = "brovKeepAlive"
|
||||
static var isInstalled: Bool { FileManager.default.fileExists(atPath: plistURL.path) }
|
||||
/// Started by launchd (the job's process)?
|
||||
static var isManaged: Bool { ProcessInfo.processInfo.environment["XPC_SERVICE_NAME"] == label }
|
||||
|
||||
static var plist: String {
|
||||
let exe = installedAppPath + "/Contents/MacOS/BroV"
|
||||
return """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>\(label)</string>
|
||||
<key>ProgramArguments</key><array><string>\(exe)</string></array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
||||
<key>ThrottleInterval</key><integer>10</integer>
|
||||
<key>ProcessType</key><string>Interactive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
}
|
||||
|
||||
/// Called at launch: installs on first run (setting on by default) and hands this
|
||||
/// instance over to launchd so the running copy is the protected one.
|
||||
@MainActor
|
||||
static func ensure() {
|
||||
let ud = UserDefaults.standard
|
||||
if ud.object(forKey: enabledKey) == nil { ud.set(true, forKey: enabledKey) }
|
||||
guard ud.bool(forKey: enabledKey) else { return }
|
||||
// Only the installed copy is managed (not a build run from Xcode / DerivedData).
|
||||
guard Bundle.main.bundlePath == installedAppPath else { return }
|
||||
guard !isManaged else { return }
|
||||
// Loop guard: if launchd's instance isn't recognised as managed, never hand over
|
||||
// again within a minute (otherwise every start would quit and restart).
|
||||
let last = ud.double(forKey: "brovHandoverAt")
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - last > 60 else { return }
|
||||
ud.set(now, forKey: "brovHandoverAt")
|
||||
// Opened by hand (or first run): hand this instance over to launchd. No other
|
||||
// BroV is running here (AppDelegate exits duplicates before calling us).
|
||||
install(handOver: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func install(handOver: Bool) {
|
||||
try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try? plist.write(to: plistURL, atomically: true, encoding: .utf8)
|
||||
// Bootstrap after we quit, so launchd starts the only instance.
|
||||
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; launchctl bootstrap gui/\(getuid()) '\(plistURL.path)'")
|
||||
if handOver { NSApp.terminate(nil) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func uninstall() {
|
||||
// bootout ends this very process; reopen it unmanaged afterwards.
|
||||
detached("sleep 1; launchctl bootout gui/\(getuid())/\(label) 2>/dev/null; rm -f '\(plistURL.path)'; open '\(installedAppPath)'")
|
||||
}
|
||||
|
||||
static func isLoaded() -> Bool {
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||
p.arguments = ["print", "gui/\(getuid())/\(label)"]
|
||||
p.standardOutput = FileHandle.nullDevice
|
||||
p.standardError = FileHandle.nullDevice
|
||||
try? p.run()
|
||||
p.waitUntilExit()
|
||||
return p.terminationStatus == 0
|
||||
}
|
||||
|
||||
private static func detached(_ script: String) {
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
p.arguments = ["-c", "(\(script)) >/dev/null 2>&1 &"]
|
||||
try? p.run()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watching agent processes
|
||||
|
||||
enum AgentKind: String, Codable {
|
||||
case claude, codex
|
||||
var title: String { self == .claude ? "Claude Code" : "Codex" }
|
||||
}
|
||||
|
||||
struct CrashedSession: Identifiable, Equatable {
|
||||
let id: String // session id
|
||||
let kind: AgentKind
|
||||
let cwd: String
|
||||
let at: Date
|
||||
var project: String { (cwd as NSString).lastPathComponent }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AgentWatch {
|
||||
static let shared = AgentWatch()
|
||||
static let autoKey = "agentAutoRestart"
|
||||
|
||||
private struct Watched {
|
||||
let pid: pid_t
|
||||
let kind: AgentKind
|
||||
let cwd: String
|
||||
let pillId: String
|
||||
}
|
||||
private var watched: [String: Watched] = [:] // by session id
|
||||
private var restarts: [String: [Date]] = [:] // crash-loop guard
|
||||
private var timer: Timer?
|
||||
|
||||
func start() {
|
||||
guard timer == nil else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
Task { @MainActor in AgentWatch.shared.check() }
|
||||
}
|
||||
}
|
||||
|
||||
/// From the hook server, for every event carrying `brov_agent_pid`.
|
||||
func register(sessionId: String, pid: Int, kind: String, cwd: String, pillId: String) {
|
||||
guard !sessionId.isEmpty, pid > 1, let k = AgentKind(rawValue: kind) else { return }
|
||||
watched[sessionId] = Watched(pid: pid_t(pid), kind: k, cwd: cwd, pillId: pillId)
|
||||
AppState.shared.crashedSessions.removeAll { $0.id == sessionId }
|
||||
}
|
||||
|
||||
func ended(sessionId: String) {
|
||||
watched.removeValue(forKey: sessionId)
|
||||
}
|
||||
|
||||
private func check() {
|
||||
let state = AppState.shared
|
||||
for (sid, w) in watched where kill(w.pid, 0) != 0 && errno == ESRCH {
|
||||
watched.removeValue(forKey: sid)
|
||||
// Gone while idle = the user closed it. Gone mid-turn = it fell over.
|
||||
let busy: Set<BotState> = [.working, .thinking, .searching, .approval, .question]
|
||||
let taskState = state.tasks.first(where: { $0.id == w.pillId })?.state ?? .idle
|
||||
guard busy.contains(taskState) else { continue }
|
||||
let crash = CrashedSession(id: sid, kind: w.kind, cwd: w.cwd, at: Date())
|
||||
state.crashedSessions.removeAll { $0.id == sid }
|
||||
state.crashedSessions.append(crash)
|
||||
state.updateTask(id: w.pillId, state: .error)
|
||||
SoundEngine.shared.play("error")
|
||||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised)
|
||||
appendAppLog("watch.log", "\(w.kind.rawValue) \(sid.prefix(8)) died mid-turn in \(w.cwd)")
|
||||
if UserDefaults.standard.bool(forKey: Self.autoKey) { revive(crash, auto: true) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopens the session in Terminal. Auto mode allows at most 2 restarts per 10 minutes.
|
||||
func revive(_ crash: CrashedSession, auto: Bool = false) {
|
||||
let now = Date()
|
||||
var times = (restarts[crash.id] ?? []).filter { now.timeIntervalSince($0) < 600 }
|
||||
if auto && times.count >= 2 {
|
||||
appendAppLog("watch.log", "auto-restart of \(crash.id.prefix(8)) skipped: crash loop")
|
||||
return
|
||||
}
|
||||
times.append(now)
|
||||
restarts[crash.id] = times
|
||||
let cmd = crash.kind == .claude
|
||||
? "claude --resume \(shellQuote(crash.id))"
|
||||
: "codex resume \(shellQuote(crash.id))"
|
||||
TerminalLauncher.run("cd \(shellQuote(crash.cwd)) && \(cmd)", name: "brov-resume")
|
||||
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
|
||||
}
|
||||
|
||||
func dismiss(_ crash: CrashedSession) {
|
||||
AppState.shared.crashedSessions.removeAll { $0.id == crash.id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login status
|
||||
|
||||
@MainActor
|
||||
final class AgentAuth {
|
||||
static let shared = AgentAuth()
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func start() {
|
||||
guard task == nil else { return }
|
||||
task = Task {
|
||||
while !Task.isCancelled {
|
||||
await refresh()
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
async let c = Self.claudeLoggedIn()
|
||||
async let x = Self.codexLoggedIn()
|
||||
let (claude, codex) = await (c, x)
|
||||
let s = AppState.shared
|
||||
// nil = CLI not installed / unknown: keep what we had.
|
||||
if let claude { s.claudeNeedsLogin = !claude }
|
||||
if let codex { s.codexNeedsLogin = !codex }
|
||||
}
|
||||
|
||||
/// Marks a CLI as logged out after an auth error seen elsewhere (chat, hook message).
|
||||
func reportAuthError(_ kind: AgentKind) {
|
||||
if kind == .claude { AppState.shared.claudeNeedsLogin = true }
|
||||
else { AppState.shared.codexNeedsLogin = true }
|
||||
}
|
||||
|
||||
static func looksLikeAuthError(_ text: String) -> Bool {
|
||||
let t = text.lowercased()
|
||||
return ["authenticate", "not logged in", "/login", "oauth", "401", "unauthorized",
|
||||
"invalid api key", "please log in", "login required"].contains { t.contains($0) }
|
||||
}
|
||||
|
||||
func login(_ kind: AgentKind) {
|
||||
TerminalLauncher.run(kind == .claude ? "claude /login" : "codex login", name: "brov-login")
|
||||
}
|
||||
|
||||
private nonisolated static func claudeLoggedIn() async -> Bool? {
|
||||
guard let bin = ClaudeCodeCLI.resolveBinary(custom: "") else { return nil }
|
||||
guard let out = await run(bin, ["auth", "status"]) else { return nil }
|
||||
if let data = out.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ok = json["loggedIn"] as? Bool { return ok }
|
||||
return nil
|
||||
}
|
||||
|
||||
private nonisolated static func codexLoggedIn() async -> Bool? {
|
||||
let fm = FileManager.default
|
||||
let candidates = ["/opt/homebrew/bin/codex", "/usr/local/bin/codex",
|
||||
fm.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/codex").path,
|
||||
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex"]
|
||||
guard let bin = candidates.first(where: { fm.isExecutableFile(atPath: $0) }) else { return nil }
|
||||
guard let out = await run(bin, ["login", "status"]) else { return nil }
|
||||
let t = out.lowercased()
|
||||
if t.contains("not logged in") { return false }
|
||||
if t.contains("logged in") { return true }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Runs a CLI briefly (20 s cap), stdin closed, without the nested-session markers.
|
||||
private nonisolated static func run(_ bin: String, _ args: [String]) async -> String? {
|
||||
await withCheckedContinuation { cont in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: bin)
|
||||
p.arguments = args
|
||||
p.environment = ClaudeCodeCLI.childEnvironment()
|
||||
p.standardInput = FileHandle.nullDevice
|
||||
let pipe = Pipe()
|
||||
p.standardOutput = pipe
|
||||
p.standardError = pipe
|
||||
guard (try? p.run()) != nil else { cont.resume(returning: nil); return }
|
||||
let deadline = DispatchTime.now() + 20
|
||||
DispatchQueue.global().asyncAfter(deadline: deadline) { if p.isRunning { p.terminate() } }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
p.waitUntilExit()
|
||||
cont.resume(returning: String(data: data, encoding: .utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Opening a command in Terminal
|
||||
|
||||
enum TerminalLauncher {
|
||||
/// Opens Terminal running `command` in a login shell, via a .command file
|
||||
/// (no Apple Events permission needed).
|
||||
static func run(_ command: String, name: String) {
|
||||
let dir = HookServer.supportDir.appendingPathComponent("commands")
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let url = dir.appendingPathComponent("\(name)-\(Int(Date().timeIntervalSince1970)).command")
|
||||
let script = "#!/bin/zsh -l\nunset CLAUDECODE\n\(command)\n"
|
||||
try? script.write(to: url, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
Reference in New Issue
Block a user