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 ` / `codex resume `. // 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 """ Label\(label) ProgramArguments\(exe) RunAtLoad KeepAliveSuccessfulExit ThrottleInterval10 ProcessTypeInteractive """ } /// 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 = [.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? 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: "'\\''") + "'" }