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:
maksarsanjeev
2026-10-06 22:56:39 +03:00
parent 37c01f991a
commit 0d40b78b26
9 changed files with 453 additions and 7 deletions
+300
View File
@@ -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: "'\\''") + "'"
}