Files
brov-macbook/NotchBuddy/Sources/App/Resilience.swift
T

301 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "'\\''") + "'"
}