From 0d40b78b26bdeb0fe735cfada6a4a8c0e753fc2e Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Tue, 6 Oct 2026 22:56:39 +0300 Subject: [PATCH] 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 --- NotchBuddy/Sources/App/AppDelegate.swift | 9 + NotchBuddy/Sources/App/AppGridView.swift | 75 +++++- NotchBuddy/Sources/App/AppState.swift | 5 + NotchBuddy/Sources/App/ClaudeCodeCLI.swift | 1 + NotchBuddy/Sources/App/ClaudeService.swift | 1 + NotchBuddy/Sources/App/HookServer.swift | 38 +++ NotchBuddy/Sources/App/Resilience.swift | 300 +++++++++++++++++++++ NotchBuddy/Sources/App/SettingsView.swift | 20 ++ scripts/build.sh | 11 +- 9 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 NotchBuddy/Sources/App/Resilience.swift diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index 81dee73..df080ff 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -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() diff --git a/NotchBuddy/Sources/App/AppGridView.swift b/NotchBuddy/Sources/App/AppGridView.swift index 8abb8bf..3a39e28 100644 --- a/NotchBuddy/Sources/App/AppGridView.swift +++ b/NotchBuddy/Sources/App/AppGridView.swift @@ -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 = 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)) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index d51045a..714b899 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -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 { diff --git a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift index 2e6e657..a627431 100644 --- a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift +++ b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift @@ -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. diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index dacc00c..7833d5d 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -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 } diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift index e3b1980..2497802 100644 --- a/NotchBuddy/Sources/App/HookServer.swift +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -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', '')) diff --git a/NotchBuddy/Sources/App/Resilience.swift b/NotchBuddy/Sources/App/Resilience.swift new file mode 100644 index 0000000..033960e --- /dev/null +++ b/NotchBuddy/Sources/App/Resilience.swift @@ -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 ` / `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: "'\\''") + "'" +} diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index b95c5e1..c5b0364 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -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) diff --git a/scripts/build.sh b/scripts/build.sh index 1e30775..ce72d26 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -10,9 +10,18 @@ xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } APP="$D/Build/Products/${1:-Debug}/BroV.app" +# Stop the running copy. If launchd keeps it alive, unload the job first or it would +# restart the old binary mid-copy. +PLIST="$HOME/Library/LaunchAgents/local.maksar.brov.plist" +launchctl bootout "gui/$(id -u)/local.maksar.brov" 2>/dev/null || true pkill -x BroV 2>/dev/null || true +sleep 0.5 mkdir -p "$HOME/Applications" rm -rf "$HOME/Applications/BroV.app" cp -R "$APP" "$HOME/Applications/BroV.app" -open "$HOME/Applications/BroV.app" +if [ -f "$PLIST" ]; then + launchctl bootstrap "gui/$(id -u)" "$PLIST" +else + open "$HOME/Applications/BroV.app" +fi echo "BroV installed to ~/Applications/BroV.app and launched"