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
+9
View File
@@ -16,8 +16,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Warm up Keychain cache on main thread BEFORE any poller or view touches it
_ = KeychainStore.shared
NSApp.setActivationPolicy(.accessory)
// One BroV at a time (launchd may start one while another is still quitting).
let me = ProcessInfo.processInfo.processIdentifier
if NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
.contains(where: { $0.processIdentifier != me }) {
exit(0)
}
BroVLaunchAgent.ensure()
setupMenuBarItem()
CodexUsageMonitor.shared.start()
AgentWatch.shared.start()
AgentAuth.shared.start()
setupIsland()
#if PHONE_LINK
CloudProbe.shared.startIfEnabled()
+69 -6
View File
@@ -39,6 +39,38 @@ struct AppGridView: View {
return PillLimit(text: text, color: ClaudePlanGauge.color(for: pct))
}
/// What Claude Code / Codex need from us, most urgent first:
/// a session fell over → login needed → waiting for an answer → working.
private func alert(for kind: AgentKind) -> PillAlert? {
if let crash = state.crashedSessions.last(where: { $0.kind == kind }) {
return PillAlert(text: "упала · \(crash.project) · поднять", color: "#F4505E") {
AgentWatch.shared.revive(crash)
}
}
if kind == .claude ? state.claudeNeedsLogin : state.codexNeedsLogin {
return PillAlert(text: "нужен вход · войти", color: "#F4505E") {
AgentAuth.shared.login(kind)
}
}
let pillIds: Set<String> = kind == .claude ? ["integration_claude", "agent_cursor"] : ["agent_codex"]
let tasks = state.tasks.filter { pillIds.contains($0.id) }
if let t = tasks.first(where: { $0.state == .approval || $0.state == .question }) {
let isApproval = t.state == .approval
return PillAlert(text: isApproval ? "ждёт разрешения · открыть" : "задал вопрос · открыть",
color: "#F5A524") {
state.setFocus(t.id)
state.view = isApproval ? .approval : .question
}
}
if let t = tasks.first(where: { [.working, .thinking, .searching].contains($0.state) }) {
return PillAlert(text: "работает · \(t.name)", color: "#3B9EFF")
}
if tasks.contains(where: { $0.state == .ratelimit }) {
return PillAlert(text: "упёрся в лимит", color: "#FB923C")
}
return nil
}
/// "23% · сброс сб 15:22" — ChatGPT weekly limit from the Codex logs.
private var chatGPTLimit: PillLimit? {
guard let w = state.codexWeekly else { return nil }
@@ -56,8 +88,8 @@ struct AppGridView: View {
EmptyPill()
}
HStack(spacing: 8) {
AppPill(app: DesktopApps.claude, limit: claudeLimit)
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit)
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
AppPill(app: DesktopApps.chatGPT, limit: chatGPTLimit, alert: alert(for: .codex))
}
}
.padding(.leading, 104)
@@ -87,9 +119,17 @@ struct PillLimit {
let color: String
}
/// Something the agent needs from us, shown instead of the limit line.
struct PillAlert {
let text: String
let color: String
var action: (() -> Void)? = nil
}
struct AppPill: View {
let app: DesktopAppSlot
var limit: PillLimit? = nil
var alert: PillAlert? = nil
@State private var appURL: URL?
@State private var isRunning = false
@State private var isHovered = false
@@ -100,8 +140,7 @@ struct AppPill: View {
}
var body: some View {
Button(action: open) {
HStack(spacing: 10) {
HStack(spacing: 10) {
icon
.frame(width: 26, height: 26)
VStack(alignment: .leading, spacing: 2) {
@@ -114,6 +153,9 @@ struct AppPill: View {
.font(.system(size: 9, weight: .semibold))
.foregroundColor(isHovered ? Color(hex: app.color) : Color(hex: "#5F646D"))
}
if let alert {
alertRow(alert)
} else {
HStack(spacing: 5) {
Circle()
.fill(isRunning ? Color(hex: "#34D399") : Color(hex: "#5F646D"))
@@ -133,6 +175,7 @@ struct AppPill: View {
.minimumScaleFactor(0.8)
}
}
}
}
}
.padding(.horizontal, 12)
@@ -146,8 +189,7 @@ struct AppPill: View {
.stroke(Color(hex: app.color).opacity(isHovered ? 0.6 : 0.22), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onTapGesture(perform: open)
.scaleEffect(isHovered ? 1.02 : 1)
.onHover { h in withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } }
.onAppear(perform: refresh)
@@ -155,6 +197,27 @@ struct AppPill: View {
.onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)) { _ in refresh() }
}
/// Status that needs attention: pulsing dot + text; tappable when it has an action.
@ViewBuilder private func alertRow(_ alert: PillAlert) -> some View {
let row = HStack(spacing: 5) {
Circle()
.fill(Color(hex: alert.color))
.frame(width: 6, height: 6)
.shadow(color: Color(hex: alert.color), radius: 3)
Text(alert.text)
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(Color(hex: alert.color))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
if let action = alert.action {
Button(action: action) { row.contentShape(Rectangle()) }
.buttonStyle(.plain)
} else {
row
}
}
@ViewBuilder private var icon: some View {
if let appURL {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
+5
View File
@@ -432,6 +432,11 @@ final class AppState: ObservableObject {
/// Current chat island height (drag, else detent).
var chatIslandHeight: CGFloat { chatDragHeight ?? chatDetentHeight(chatDetent) }
// MARK: Agent health (BroV)
@Published var crashedSessions: [CrashedSession] = []
@Published var claudeNeedsLogin = false
@Published var codexNeedsLogin = false
/// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor.
@Published var codexWeekly: PlanWindow? = nil
@Published var claudePlanUsage: PlanUsage? = nil {
@@ -149,6 +149,7 @@ final class ClaudeCodeCLI {
if let error = parser.errorMessage {
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
AgentAuth.shared.reportAuthError(.claude)
throw ClaudeCodeCLIError.failed("Claude Code не авторизован. Выполните `claude /login` в терминале.")
}
// A resume that points at a gone session: start fresh next time.
@@ -317,6 +317,7 @@ final class ClaudeService {
}
state.stateOverride = nil
state.view = .prompt
state.claudeNeedsLogin = false
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
+38
View File
@@ -414,6 +414,20 @@ final class HookServer: @unchecked Sendable {
// Approval dismissed — fall through so the resolving event updates state normally.
}
// BroV: crash watch + login status.
if let pid = (payload["brov_agent_pid"] as? NSNumber)?.intValue {
AgentWatch.shared.register(sessionId: sessionId, pid: pid,
kind: payload["brov_agent_kind"] as? String ?? "",
cwd: cwd, pillId: agentId)
}
if name == "SessionEnd" { AgentWatch.shared.ended(sessionId: sessionId) }
if name == "Notification" || name == "StopFailure" {
let text = (payload["message"] as? String ?? "") + " " + (payload["error"] as? String ?? "")
if AgentAuth.looksLikeAuthError(text) {
AgentAuth.shared.reportAuthError(isCodexEvent ? .codex : .claude)
}
}
switch name {
case "SessionStart":
@@ -2051,6 +2065,30 @@ def main():
if agent:
payload.setdefault('coucou_agent', agent)
# BroV crash watch: on session start / prompt, report the agent process (claude/codex)
# found by walking up the parent chain, so BroV can notice when it dies mid-turn.
try:
ev0 = payload.get('hook_event_name', '') or arg_event
if ev0 in ('SessionStart', 'UserPromptSubmit', 'session_start', 'user_prompt_submit'):
import subprocess
pid = os.getppid()
for _ in range(8):
out = subprocess.run(['ps', '-o', 'ppid=,comm=', '-p', str(pid)],
capture_output=True, text=True, timeout=1).stdout.strip()
if not out:
break
ppid_s, _, comm = out.partition(' ')
name = os.path.basename(comm.strip())
if name in ('claude', 'codex'):
payload['brov_agent_pid'] = pid
payload['brov_agent_kind'] = name
break
pid = int(ppid_s)
if pid <= 1:
break
except Exception:
pass
# Enrich with terminal context
env = os.environ
payload.setdefault('term_program', env.get('TERM_PROGRAM', ''))
+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: "'\\''") + "'"
}
+20
View File
@@ -228,6 +228,26 @@ struct SettingsView: View {
// MARK: - General section
@ViewBuilder private var generalSection: some View {
GroupBox("Надёжность") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Поднимать BroV, если он упал, и запускать при входе в систему", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: BroVLaunchAgent.enabledKey) },
set: { on in
UserDefaults.standard.set(on, forKey: BroVLaunchAgent.enabledKey)
if on { BroVLaunchAgent.install(handOver: !BroVLaunchAgent.isManaged) }
else if BroVLaunchAgent.isInstalled { BroVLaunchAgent.uninstall() }
}))
Text("Если выйти через меню, BroV не вернётся. Работает для копии в ~/Applications.")
.font(.system(size: 11)).foregroundColor(.secondary)
Toggle("Сразу поднимать упавшие сессии Claude Code и Codex", isOn: Binding(
get: { UserDefaults.standard.bool(forKey: AgentWatch.autoKey) },
set: { UserDefaults.standard.set($0, forKey: AgentWatch.autoKey) }))
Text("Сессия считается упавшей, если её процесс пропал посреди работы. Без галочки в пилюле появится кнопка «поднять». Не чаще 2 раз за 10 минут.")
.font(.system(size: 11)).foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Звук") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Включить звуки", isOn: $state.soundEnabled)