import Foundation import Darwin import AppKit import SwiftUI import CryptoKit // MARK: - HookServer // Listens on a Unix domain socket for events from nb-hook (Claude Code hooks). // Thread-safe: socket I/O on background threads, state updates dispatched to main queue. final class HookServer: @unchecked Sendable { static let shared = HookServer() // Support directory paths static var supportDir: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("NotchBuddy") } static var socketPath: String { #if APPSTORE // Container home root keeps path ≤ 103 bytes (sun_path limit on macOS is 104 incl. NUL) // /Users/louis/Library/Containers/fr.louisraille.BroV/Data/nb.sock = 66 bytes ✓ return NSHomeDirectory() + "/nb.sock" #else return supportDir.appendingPathComponent("nb.sock").path #endif } // hookScriptPath is only used by the non-App Store build. // App Store build derives the command from the panel-selected claudeURL in buildHooksData(claudeURL:). static var hookScriptPath: String { supportDir.appendingPathComponent("nb-hook").path } // No approval blocking state — notch is notification-only, user answers in VS Code private static let maxPayload = 1_048_576 // 1 MB — reject oversized messages private static let receiveTimeoutSeconds: Int = 5 // SO_RCVTIMEO on client sockets private static let maxConnections = 32 // concurrent connection ceiling private var serverFD: Int32 = -1 private let connectionLock = NSLock() private var connectionCount = 0 private var pendingApprovalFD: Int32 = -1 // held open while user decides private var approvalFDSource: (any DispatchSourceRead)? = nil // monitors pendingApprovalFD private var pendingQuestionFD: Int32 = -1 // held open while user answers AskUserQuestion private var questionFDSource: (any DispatchSourceRead)? = nil // monitors pendingQuestionFD private var questionPillId: String = "" // pill that owns the pending question private var focusBeforeQuestion: String? = nil // saved focus to restore after question private var activeSessionId: String? = nil // current Claude Code session private var focusBeforeApproval: String? = nil // saved focus to restore after approval private init() {} // MARK: - Approval fd helpers @MainActor private func cancelApprovalFDSource() { approvalFDSource?.cancel() approvalFDSource = nil } /// Cancels the approval fd source (which closes the fd via its cancel handler), shows a /// 3-second note, clears approval state, then collapses the island. @MainActor private func dismissApprovalCard(note: String) { // cancelApprovalFDSource() triggers the cancel handler which closes the fd. // Never close the fd here directly — Apple requires it to happen in the cancel handler. cancelApprovalFDSource() pendingApprovalFD = -1 let state = AppState.shared let pillId = state.pendingApproval?.pillId ?? "integration_claude" state.pendingApproval = nil state.isPinned = false state.updateTask(id: pillId, state: .working) clearPillBadge(id: pillId) // Restore focus to the pill that was focused before the approval card appeared. if let prev = focusBeforeApproval { focusBeforeApproval = nil if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } } } state.noteMessage = note state.view = .note DispatchQueue.main.asyncAfter(deadline: .now() + 3) { NotificationCenter.default.post(name: .islandCollapse, object: nil) } } // MARK: - Question fd helpers @MainActor private func cancelQuestionFDSource() { questionFDSource?.cancel() questionFDSource = nil } @MainActor private func dismissQuestionCard(note: String) { cancelQuestionFDSource() pendingQuestionFD = -1 let state = AppState.shared let pillId = questionPillId state.pendingQuestion = nil state.isPinned = false state.updateTask(id: pillId, state: .working) clearPillBadge(id: pillId) if let prev = focusBeforeQuestion { focusBeforeQuestion = nil if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } } } if !note.isEmpty { state.noteMessage = note state.view = .note DispatchQueue.main.asyncAfter(deadline: .now() + 3) { NotificationCenter.default.post(name: .islandCollapse, object: nil) } } else { state.view = state.tasks.isEmpty ? .empty : .overview } } /// Called by QuestionView. Sends answers JSON and cleans up. @MainActor func sendQuestionAnswers(_ answers: [String: Any]) { let fd = pendingQuestionFD pendingQuestionFD = -1 let source = questionFDSource questionFDSource = nil if fd >= 0, let data = try? JSONSerialization.data(withJSONObject: ["permissionDecision": "answer", "answers": answers], options: .withoutEscapingSlashes), let json = String(data: data, encoding: .utf8) { Task.detached { [weak self] in self?.sendLine(fd: fd, text: json) DispatchQueue.main.async { source?.cancel() } } } else { source?.cancel() } dismissQuestionCard(note: "") } /// Called by QuestionView.onDisappear — card left screen without an explicit answer. /// Sends "ask" immediately to unblock nb-hook; does NOT navigate (view already changed). @MainActor func releaseQuestionFD() { guard pendingQuestionFD >= 0 else { return } let fd = pendingQuestionFD pendingQuestionFD = -1 let source = questionFDSource questionFDSource = nil AppState.shared.pendingQuestion = nil Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) DispatchQueue.main.async { source?.cancel() } } } /// Called by QuestionView "Reply in terminal" button. @MainActor func sendQuestionAsk() { let fd = pendingQuestionFD pendingQuestionFD = -1 let source = questionFDSource questionFDSource = nil if fd >= 0 { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) DispatchQueue.main.async { source?.cancel() } } } else { source?.cancel() } dismissQuestionCard(note: "") } /// Returns the tool_input serialized as sorted-keys JSON, "" if absent or empty. /// Same computation used in processPermissionRequest and processEvent to match PostToolUse. private static func approvalInputKey(_ input: [String: Any]) -> String { guard !input.isEmpty, let data = try? JSONSerialization.data(withJSONObject: input, options: .sortedKeys), let str = String(data: data, encoding: .utf8) else { return "" } return str } // MARK: - Start func start() { // Ensure support directory exists (mode 0700 — not world-readable) let dir = Self.supportDir try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.setAttributes([.posixPermissions: 0o700 as NSNumber], ofItemAtPath: dir.path) #if !APPSTORE installHookScript() #endif Thread.detachNewThread { self.serverThread() } } // MARK: - Socket server (background thread) private func serverThread() { let path = Self.socketPath // sun_path on macOS is 104 bytes including the NUL terminator → max 103 usable bytes let maxSunPathBytes = MemoryLayout.size - MemoryLayout.size - 1 guard path.utf8.count <= maxSunPathBytes else { NSLog("HookServer: socket path too long (\(path.utf8.count) bytes, max \(maxSunPathBytes)): \(path)") return } try? FileManager.default.removeItem(atPath: path) let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return } serverFD = fd var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let cpath = Array(path.utf8CString) withUnsafeMutableBytes(of: &addr.sun_path) { raw in for (i, c) in cpath.enumerated() where i < raw.count { raw[i] = UInt8(bitPattern: c) } } let bindRC = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } } guard bindRC == 0 else { close(fd); return } // Restrict socket to owner only chmod(path, 0o600) guard Darwin.listen(fd, 32) == 0 else { close(fd); return } while true { let clientFD = Darwin.accept(fd, nil, nil) guard clientFD >= 0 else { break } // Reject connections from other users (same-UID check) var euid: uid_t = 0 var egid: gid_t = 0 guard getpeereid(clientFD, &euid, &egid) == 0, euid == getuid() else { close(clientFD) continue } // Enforce concurrent connection ceiling connectionLock.lock() let count = connectionCount if count < Self.maxConnections { connectionCount += 1 } connectionLock.unlock() guard count < Self.maxConnections else { close(clientFD) continue } Thread.detachNewThread { self.handleClient(fd: clientFD) } } } // MARK: - Client handler (background thread) private func handleClient(fd: Int32) { defer { connectionLock.lock(); connectionCount -= 1; connectionLock.unlock() } // 5-second receive timeout — unresponsive clients don't hold threads forever var tv = timeval(tv_sec: Self.receiveTimeoutSeconds, tv_usec: 0) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) // Read newline-delimited JSON var raw = Data() var buf = [UInt8](repeating: 0, count: 4096) outer: while true { let n = recv(fd, &buf, buf.count, 0) if n <= 0 { break } for i in 0.. Self.maxPayload { break } } guard !raw.isEmpty, let payload = try? JSONSerialization.jsonObject(with: raw) as? [String: Any] else { sendLine(fd: fd, text: #"{"ok":true}"#) close(fd) return } let coucouKind = payload["coucou_kind"] as? String ?? "" // statusline payloads are handled separately — no session, no reveal, no sound if coucouKind == "statusline" { Task { @MainActor in self.processStatusLine(payload: payload) } sendLine(fd: fd, text: #"{"ok":true}"#) close(fd) return } // AskUserQuestion via --ask PreToolUse hook — hold fd open like PermissionRequest if coucouKind == "ask_user_question" { let toolInput = payload["tool_input"] as? [String: Any] ?? [:] if let parsed = AskQuestion.parse(toolInput: toolInput) { Task { @MainActor in self.processQuestionRequest(fd: fd, parsed: parsed, payload: payload) } } else { // Malformed payload — fall back: send ask so Claude Code re-asks in terminal sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) } return } let eventName = payload["hook_event_name"] as? String ?? "" if eventName == "PermissionRequest" { // Hold fd open — Claude Code waits for our decision (up to 120s) Task { @MainActor in self.processPermissionRequest(fd: fd, payload: payload) } } else { Task { @MainActor in self.processEvent(name: eventName, payload: payload) } sendLine(fd: fd, text: #"{"ok":true}"#) close(fd) } } // MARK: - Event → AppState // Claude Code events route to the permanent "integration_claude" task. // Events tagged with a valid coucou_agent route to a dynamic "integration_" task. // View switches only happen if VS Code (or the agent pill) is currently focused. // When not focused: state updates animate the mini bot in the pill; badge shown for alerts. @MainActor private func processEvent(name: String, payload: [String: Any]) { let state = AppState.shared let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) // Determine which pill this event belongs to. // coucou_agent must be lowercase, digits and hyphens, ≤ 24 chars. let rawAgent = payload["coucou_agent"] as? String ?? "" let validAgent = Self.validateAgent(rawAgent) let termProgram = payload["term_program"] as? String ?? "" let bundleId = payload["bundle_id"] as? String ?? "" // Cursor identified solely by its stable Electron bundle ID. // ToDesktop builds other apps too — do not match on "todesktop" alone. let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" let isVSCodeEditor = !isCursorEditor && ( termProgram.lowercased().contains("vscode") || bundleId.lowercased().contains("vscode")) // Routing: // • "codex" → agent_codex (GitHub build only: workspace pill, approvals in the notch) // • other valid coucou_agent → external pill (fire-and-forget, no approval card) // • Cursor bundle ID → agent_cursor // • VS Code → integration_claude #if !APPSTORE let isCodexEvent = rawAgent == "codex" #else let isCodexEvent = false #endif let agentId: String let isExternalAgent: Bool if isCodexEvent { agentId = "agent_codex" isExternalAgent = false } else if let agent = validAgent { agentId = "agent_\(agent)" isExternalAgent = true } else if isCursorEditor { agentId = "agent_cursor" isExternalAgent = false } else if isVSCodeEditor { agentId = "integration_claude" isExternalAgent = false } else { nbLog("Ignored \(name) from \(termProgram.isEmpty ? bundleId : termProgram) (\(projectName))") return } let focused = state.focusId == agentId #if PHONE_LINK // The iPhone's "last turn" (prompt, actions, diffs, answer). if !isExternalAgent { TurnRecorder.shared.record(event: name, payload: payload, pillId: agentId) } #endif // While a permission request is pending, dismiss when the resolving event arrives, // then continue normal processing. Only skip normal processing when unresolved. if let pending = state.pendingApproval, agentId == pending.pillId { let handledNote: String switch pending.pillId { case "agent_cursor": handledNote = "Решено в Cursor." case "agent_codex": handledNote = "Решено в Codex." default: handledNote = "Решено в VS Code." } var resolved = false switch name { case "PostToolUse", "PostToolUseFailure": // Only dismiss when this exact tool call finished — same session, tool and input. // Other parallel tools finishing must not close the card. if sessionId == pending.sessionId, (payload["tool_name"] as? String ?? "") == pending.tool, Self.approvalInputKey(payload["tool_input"] as? [String: Any] ?? [:]) == pending.inputKey { dismissApprovalCard(note: handledNote) resolved = true } case "Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt": // Turn ended or session interrupted — the permission is moot. if sessionId == pending.sessionId { dismissApprovalCard(note: handledNote) resolved = true } default: break } if !resolved { return } // 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": activeSessionId = sessionId if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } nbLog("SessionStart \(isExternalAgent ? agentId : projectName) (\(sessionId.prefix(8)))") if state.isPresent { expandIfNeeded(to: .overview) } SoundEngine.shared.play("work") case "UserPromptSubmit": activeSessionId = sessionId if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } state.updateTask(id: agentId, state: .thinking) if let prompt = payload["prompt"] as? String, !prompt.isEmpty { appendStep(id: agentId, step: String(prompt.prefix(60))) } if state.isPresent { expandIfNeeded(to: .overview) } case "PreToolUse": activeSessionId = sessionId let tool = payload["tool_name"] as? String ?? "Tool" if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } // AskUserQuestion is handled via the dedicated --ask hook. // Skip state/step update here to avoid flickering over the question card. guard tool != "AskUserQuestion" else { break } if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } state.updateTask(id: agentId, state: .working) let input = payload["tool_input"] as? [String: Any] ?? [:] let step = frenchStep(tool: tool, input: input) appendStep(id: agentId, step: step) nbLog("PreToolUse \(tool)") case "PostToolUse": state.updateTask(id: agentId, state: .working) // Live diff for Edit / MultiEdit / Write let diffTool = payload["tool_name"] as? String ?? "" let diffInput = payload["tool_input"] as? [String: Any] ?? [:] if let diff = buildFileDiff(tool: diffTool, input: diffInput, pillId: agentId) { let idx = state.appendSessionDiff(diff, for: agentId) let step = String.makeDiffStep(filename: diff.name, added: diff.added, removed: diff.removed, diffId: idx) appendStep(id: agentId, step: step) } case "PostToolUseFailure": state.updateTask(id: agentId, state: .working) appendStep(id: agentId, step: "⚠ ошибка") case "Notification": let message = payload["message"] as? String ?? "" let lower = message.lowercased() if lower.contains("rate limit") || lower.contains("limite d") { state.updateTask(id: agentId, state: .ratelimit) SoundEngine.shared.play("rate") } else if message.hasSuffix("?") { state.updateTask(id: agentId, state: .question) appendStep(id: agentId, step: message) } case "Stop": state.updateTask(id: agentId, state: .finished) let rawFinal = (payload["last_assistant_message"] as? String) ?? (payload["message"] as? String) ?? "" let finalText = DiffEngine.toOneLine(rawFinal) if !finalText.isEmpty { appendStep(id: agentId, step: finalText) if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = finalText } } SoundEngine.shared.play("finish") if focused { expandIfNeeded(to: .finished) } else { setPillBadge(id: agentId, badge: .finished) } DispatchQueue.main.asyncAfter(deadline: .now() + 5.2) { if isExternalAgent { AppState.shared.removeTask(id: agentId) } else { AppState.shared.updateTask(id: agentId, state: .idle) self.clearPillBadge(id: agentId) } } case "StopFailure": state.updateTask(id: agentId, state: .error) SoundEngine.shared.play("error") if focused { expandIfNeeded(to: .error) } else { setPillBadge(id: agentId, badge: .error) } case "Interrupt": // Codex: user stopped the turn activeSessionId = nil state.updateTask(id: agentId, state: .idle) clearPillBadge(id: agentId) case "SessionEnd": activeSessionId = nil if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } state.clearSessionDiffs(for: agentId) state.removeTask(id: agentId) case "SubagentStart": appendStep(id: agentId, step: "+ субагент") case "SubagentStop": appendStep(id: agentId, step: "• субагент готов") default: break } } // MARK: - Agent validation + dynamic pill /// Validates a coucou_agent name: lowercase, digits and hyphens, 1–24 chars. /// "claude" is reserved and rejected so it cannot impersonate the Claude Code pill. /// Returns the name unchanged if valid, nil otherwise. private static func validateAgent(_ raw: String) -> String? { guard !raw.isEmpty, raw.count <= 24, raw != "claude" else { return nil } for scalar in raw.unicodeScalars { let v = scalar.value let ok = (v >= 0x61 && v <= 0x7A) // a-z || (v >= 0x30 && v <= 0x39) // 0-9 || v == 0x2D // - guard ok else { return nil } } return raw } /// Creates a dynamic pill for a third-party agent on first event, then no-ops. /// ID format: "agent_" — never collides with "integration_*" pills. /// Inserted right after integration_claude so it appears in the visible prefix(4). @MainActor private func upsertExternalAgent(id: String, name: String) { let state = AppState.shared guard state.tasks.firstIndex(where: { $0.id == id }) == nil else { return } let color = IslandConst.colorForProject(name) let task = AgentTask(id: id, name: name, color: color, state: .idle, steps: [], source: .agent) if let claudeIdx = state.tasks.firstIndex(where: { $0.id == "integration_claude" }) { state.tasks.insert(task, at: claudeIdx + 1) } else { state.tasks.append(task) } if state.focusId == nil { state.focusId = id } state.syncMode() } // MARK: - Helpers @MainActor private func expandIfNeeded(to view: IslandView) { let state = AppState.shared let isAlert: Bool switch view { case .approval, .question, .finished, .error, .confused: isAlert = true default: isAlert = false } if state.mode == .expanded { // Approval and question always win; other alerts are blocked while a card is showing if view == .approval || view == .question { state.view = view } else if isAlert && state.pendingApproval == nil { state.view = view } } else if isAlert { // Alerts always force-expand NotificationCenter.default.post(name: .hookExpand, object: view) } else if state.mode == .hidden { // Non-alert work events: reveal compact only, never force-expand NotificationCenter.default.post(name: .hookReveal, object: nil) } // Already compact and non-alert: Mochi state update is enough, no expand } // MARK: - Status line (plan gauge) @MainActor private func processStatusLine(payload: [String: Any]) { if let usage = ClaudePlanGauge.parse(payload: payload) { AppState.shared.claudePlanUsage = usage } } // MARK: - Permission request (blocking — Claude Code waits for decision) @MainActor private func processPermissionRequest(fd: Int32, payload: [String: Any]) { let state = AppState.shared let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) let rawAgent = payload["coucou_agent"] as? String ?? "" let termProgram = payload["term_program"] as? String ?? "" let bundleId = payload["bundle_id"] as? String ?? "" let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" let isVSCodeEditor = !isCursorEditor && ( termProgram.lowercased().contains("vscode") || bundleId.lowercased().contains("vscode")) // Codex gets the same approval card as Claude Code / Cursor (GitHub build only). // Other external agents (any other coucou_agent) answer immediately with "ask" // so the agent re-asks in its own terminal — they do not get a notch card. #if !APPSTORE let isCodexRequest = rawAgent == "codex" #else let isCodexRequest = false #endif if !isCodexRequest && Self.validateAgent(rawAgent) != nil { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) } return } // Determine which workspace pill owns the request. let pillId: String if isCodexRequest { pillId = "agent_codex" } else if isCursorEditor { pillId = "agent_cursor" } else { pillId = "integration_claude" } guard isCodexRequest || isCursorEditor || isVSCodeEditor else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) } return } let tool = payload["tool_name"] as? String ?? "Tool" let toolInput = payload["tool_input"] as? [String: Any] ?? [:] let inputKey = Self.approvalInputKey(toolInput) nbLog("PermissionRequest \(tool) [\(pillId)]") // AskUserQuestion is now handled via the dedicated --ask PreToolUse hook. // If it still arrives here as a PermissionRequest, reply "ask" so Claude Code // re-asks in the terminal — never show the question twice. if tool == "AskUserQuestion" { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) } return } let command = DangerCheck.describe(tool: tool, input: toolInput) let danger = DangerCheck.reasons(tool: tool, input: toolInput) if pendingApprovalFD >= 0 { // Displace the previous request: write "ask" then cancel its source. // The cancel handler closes the old fd — never close it directly. let old = pendingApprovalFD let oldSource = approvalFDSource approvalFDSource = nil Task.detached { [weak self] in // "ask" → nb-hook outputs nothing → Claude Code re-asks self?.sendLine(fd: old, text: #"{"permissionDecision":"ask"}"#) DispatchQueue.main.async { oldSource?.cancel() } } } pendingApprovalFD = fd activeSessionId = sessionId upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) state.updateTask(id: pillId, state: .approval) state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, command: command, inputKey: inputKey, pillId: pillId, danger: danger) if !danger.isEmpty { NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) } state.isPinned = true SoundEngine.shared.play("approval") // Approval always forces the island open — user must be able to respond. // Save current focus so we can restore it when the card is dismissed. if focusBeforeApproval == nil { focusBeforeApproval = state.focusId } withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = pillId } expandIfNeeded(to: .approval) // Monitor fd: if the editor closes the connection (handled externally), dismiss the card. // The cancel handler closes the fd — never close it anywhere else. let capturedPillId = pillId let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) source.setEventHandler { [weak self] in guard let self, self.pendingApprovalFD == fd else { return } let note: String switch capturedPillId { case "agent_cursor": note = "Решено в Cursor." case "agent_codex": note = "Решено в Codex." default: note = "Решено в VS Code." } self.dismissApprovalCard(note: note) } source.setCancelHandler { close(fd) } source.resume() approvalFDSource = source // 115s safety timeout — show a note and cancel without sending a decision. // nb-hook reads EOF from the cancel handler's close and exits; Claude Code / Codex re-asks. let captured = fd DispatchQueue.main.asyncAfter(deadline: .now() + 115) { [weak self] in guard let self, self.pendingApprovalFD == captured else { return } let note: String switch capturedPillId { case "agent_cursor": note = "Всё ещё ждёт в Cursor." case "agent_codex": note = "Всё ещё ждёт в Codex." default: note = "Всё ещё ждёт в VS Code." } self.dismissApprovalCard(note: note) } } /// Called by ApprovalView buttons. Writes the decision to the waiting nb-hook and cleans up. @MainActor func sendApprovalDecision(_ decision: String) { let fd = pendingApprovalFD pendingApprovalFD = -1 // Capture source before nulling — we send the decision first, then cancel the source. // The cancel handler closes the fd; never close it directly. let source = approvalFDSource approvalFDSource = nil let json: String switch decision { case "allow": json = #"{"permissionDecision":"allow"}"# case "always": json = #"{"permissionDecision":"always"}"# case "ask": json = #"{"permissionDecision":"ask"}"# default: json = #"{"permissionDecision":"deny"}"# } if fd >= 0 { Task.detached { [weak self] in // Write decision while fd is still valid, then cancel source → cancel handler closes fd self?.sendLine(fd: fd, text: json) DispatchQueue.main.async { source?.cancel() } } } else { source?.cancel() } let state = AppState.shared let pillId = state.pendingApproval?.pillId ?? "integration_claude" state.pendingApproval = nil state.isPinned = false state.updateTask(id: pillId, state: .working) clearPillBadge(id: pillId) // Restore focus to the pill that was focused before the approval card appeared. if let prev = focusBeforeApproval { focusBeforeApproval = nil if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } } } state.view = state.tasks.isEmpty ? .empty : .overview } // MARK: - Question request @MainActor private func processQuestionRequest(fd: Int32, parsed: AskQuestion, payload: [String: Any]) { let state = AppState.shared let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? "unknown" let cwd = payload["cwd"] as? String ?? "" let rawName = URL(fileURLWithPath: cwd).lastPathComponent let projectName = aliasProjectName(rawName.isEmpty ? "Сессия" : rawName) let rawAgent = payload["coucou_agent"] as? String ?? "" let termProgram = payload["term_program"] as? String ?? "" let bundleId = payload["bundle_id"] as? String ?? "" let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" let isVSCodeEditor = !isCursorEditor && ( termProgram.lowercased().contains("vscode") || bundleId.lowercased().contains("vscode")) #if !APPSTORE let isCodexRequest = rawAgent == "codex" #else let isCodexRequest = false #endif let pillId: String if isCodexRequest { pillId = "agent_codex" } else if isCursorEditor { pillId = "agent_cursor" } else { pillId = "integration_claude" } guard isCodexRequest || isCursorEditor || isVSCodeEditor else { Task.detached { [weak self] in self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) close(fd) } return } // Displace any previous question waiting for an answer. if pendingQuestionFD >= 0 { let old = pendingQuestionFD let oldSrc = questionFDSource questionFDSource = nil Task.detached { [weak self] in self?.sendLine(fd: old, text: #"{"permissionDecision":"ask"}"#) DispatchQueue.main.async { oldSrc?.cancel() } } } pendingQuestionFD = fd activeSessionId = sessionId questionPillId = pillId upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) state.updateTask(id: pillId, state: .question) state.pendingQuestion = parsed state.isPinned = true SoundEngine.shared.play("approval") if focusBeforeQuestion == nil { focusBeforeQuestion = state.focusId } withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = pillId } expandIfNeeded(to: .question) let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) source.setEventHandler { [weak self] in guard let self, self.pendingQuestionFD == fd else { return } self.dismissQuestionCard(note: "") } source.setCancelHandler { close(fd) } source.resume() questionFDSource = source let captured = fd DispatchQueue.main.asyncAfter(deadline: .now() + 120) { [weak self] in guard let self, self.pendingQuestionFD == captured else { return } // Send "ask" so nb-hook exits cleanly; Claude Code re-asks in terminal. let askFD = self.pendingQuestionFD self.pendingQuestionFD = -1 let src = self.questionFDSource self.questionFDSource = nil Task.detached { [weak self] in self?.sendLine(fd: askFD, text: #"{"permissionDecision":"ask"}"#) DispatchQueue.main.async { src?.cancel() } } self.dismissQuestionCard(note: "") } } /// Updates or transiently creates a workspace pill (VS Code or Cursor) task. /// If the task already exists (persistent), just updates name/cwd. /// If missing (transient), creates it and inserts after the main pill. @MainActor private func upsertWorkspaceTask(id: String, projectName: String, cwd: String = "") { let state = AppState.shared if let idx = state.tasks.firstIndex(where: { $0.id == id }) { state.tasks[idx].name = projectName if !cwd.isEmpty { state.tasks[idx].sessionCwd = cwd } return } // Transient: create and insert after the main pill let def = PillCatalog.definition(for: id) let color = def?.color ?? "#C0C4CC" let source = def?.source ?? .agent let task = AgentTask(id: id, name: projectName, color: color, state: .idle, steps: [], source: source, isIntegration: true) if let mainIdx = state.tasks.firstIndex(where: { $0.id == state.mainPillId }) { state.tasks.insert(task, at: mainIdx + 1) } else { state.tasks.insert(task, at: 0) } if state.focusId == nil { state.focusId = id } state.syncMode() } // MARK: - Badge helpers @MainActor private func setPillBadge(id: String, badge: PillBadge) { let state = AppState.shared guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } state.tasks[idx].pillBadge = badge } @MainActor private func clearPillBadge(id: String) { let state = AppState.shared guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } state.tasks[idx].pillBadge = nil } @MainActor private func appendStep(id: String, step: String) { let state = AppState.shared guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } state.tasks[idx].steps.append(step) if state.tasks[idx].steps.count > 20 { state.tasks[idx].steps.removeFirst() } state.tasks[idx].stepIndex = state.tasks[idx].steps.count - 1 } // MARK: - Project name alias mapping private func aliasProjectName(_ name: String) -> String { let aliases: [String: String] = [ "notch-buddy": "Notch Buddy", "notchbuddy": "Notch Buddy", "notch_buddy": "Notch Buddy", ] return aliases[name.lowercased()] ?? name } // MARK: - Step labels (Russian) private func frenchStep(tool: String, input: [String: Any]) -> String { let labels: [String: String] = [ "Bash": "Выполняет", "Read": "Читает", "Write": "Пишет", "Edit": "Правит", "Glob": "Ищет", "Grep": "Ищет", "WebSearch": "Веб-поиск", "WebFetch": "Загружает", "TodoWrite": "Задачи", "Task": "Агент", "LS": "Список", "MultiEdit": "Правит", "NotebookEdit": "Блокнот", // Codex tools "apply_patch": "Правит", "update_plan": "Задачи", "spawn_agent": "Агент", ] var label = labels[tool] ?? tool // Codex MCP tools arrive as mcp__server__tool — show "server · tool" if tool.hasPrefix("mcp__") { let rest = String(tool.dropFirst(5)) let parts = rest.components(separatedBy: "__") label = parts.count >= 2 ? "\(parts[0]) · \(parts.dropFirst().joined(separator: "__"))" : rest } // Bash: infer a more precise verb from the command if tool == "Bash", let cmd = input["command"] as? String { return "\(bashVerb(cmd)) · \(oneLine(cmd))" } // apply_patch: extract the first file name from the patch if tool == "apply_patch", let patch = input["command"] as? String { for line in patch.split(separator: "\n") { for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] { if line.hasPrefix(prefix) { let path = String(line.dropFirst(prefix.count)) return "\(label) · \(URL(fileURLWithPath: path).lastPathComponent)" } } } return label } if let cmd = input["command"] as? String { return "\(label) · \(oneLine(cmd))" } else if let path = input["path"] as? String { return "\(label) · \(URL(fileURLWithPath: path).lastPathComponent)" } else if let file = input["file_path"] as? String { return "\(label) · \(URL(fileURLWithPath: file).lastPathComponent)" } else if let query = input["query"] as? String { return "\(label) · \(oneLine(query))" } return label } /// Infers a Russian verb from a shell command's first word. private func bashVerb(_ command: String) -> String { let first = command.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? "" switch first { case "cat", "bat", "head", "tail", "less", "more", "nl": return "Читает" case "rg", "grep", "find", "fd", "ls", "tree", "wc": return "Ищет" default: break } let testRunners = ["pytest", "vitest", "jest", "npm test", "npm run test", "cargo test", "go test", "swift test", "make test", "xcodebuild test", "unittest"] if testRunners.contains(where: { command.contains($0) }) { return "Тестирует" } return "Выполняет" } // MARK: - Live diff helpers @MainActor private func buildFileDiff(tool: String, input: [String: Any], pillId: String) -> FileDiff? { switch tool { case "Edit": guard let old = input["old_string"] as? String, let new = input["new_string"] as? String, let path = input["file_path"] as? String, !old.isEmpty || !new.isEmpty else { return nil } let d = DiffEngine.fromEdit(old: old, new: new, path: path) return (d.added > 0 || d.removed > 0) ? d : nil case "MultiEdit": guard let path = input["file_path"] as? String, let edits = input["edits"] as? [[String: Any]], !edits.isEmpty else { return nil } var totalAdded = 0, totalRemoved = 0, allHunks: [DiffHunk] = [], anyLarge = false for edit in edits { guard let old = edit["old_string"] as? String, let new = edit["new_string"] as? String else { continue } let d = DiffEngine.fromEdit(old: old, new: new, path: path) totalAdded += d.added; totalRemoved += d.removed allHunks.append(contentsOf: d.hunks); if d.tooLarge { anyLarge = true } } guard totalAdded > 0 || totalRemoved > 0 else { return nil } return FileDiff(path: path, added: totalAdded, removed: totalRemoved, hunks: allHunks, tooLarge: anyLarge, isNewFile: false) case "Write": guard let path = input["file_path"] as? String, let content = input["content"] as? String, !content.isEmpty else { return nil } let d = DiffEngine.fromNew(content: content, path: path) return (d.added > 0 || d.removed > 0) ? d : nil default: return nil } } /// Collapses whitespace so a multi-line command stays one ticker row. private func oneLine(_ text: String, limit: Int = 60) -> String { let collapsed = text.split(whereSeparator: { $0.isNewline || $0 == "\t" }) .joined(separator: " ") return collapsed.count > limit ? String(collapsed.prefix(limit)) + "…" : collapsed } // MARK: - Logging private func nbLog(_ message: String) { appendAppLog("nb.log", message) } private func sendLine(fd: Int32, text: String) { let bytes = Array((text + "\n").utf8) bytes.withUnsafeBytes { buffer in var sent = 0 while sent < buffer.count { let n = Darwin.send(fd, buffer.baseAddress! + sent, buffer.count - sent, 0) if n <= 0 { break } sent += n } } } // MARK: - nb-hook script installation func installHookScript() { #if APPSTORE // In App Store mode the script is written during settings hook installation // (requires NSOpenPanel to ~/.claude chosen by the user) #else let dir = Self.supportDir try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.setAttributes([.posixPermissions: 0o700 as NSNumber], ofItemAtPath: dir.path) // nb-hook: shell wrapper (always exits 0, calls nb-hook.py via python3) let wrapperURL = URL(fileURLWithPath: Self.hookScriptPath) try? nbHookShellWrapper.write(to: wrapperURL, atomically: true, encoding: .utf8) _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: wrapperURL.path) // nb-hook.py: Python relay let pyURL = wrapperURL.deletingLastPathComponent().appendingPathComponent("nb-hook.py") try? nbHookPythonGitHub.write(to: pyURL, atomically: true, encoding: .utf8) _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: pyURL.path) #endif } // MARK: - Outdated hook detection /// Returns true if settings.json has a BroV hook that needs updating: /// either a PermissionRequest hook with timeout < 120s, or the AskUserQuestion /// PreToolUse matcher is missing (requires Claude Code 2.1.85+). static func hooksNeedUpdate() -> Bool { let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") guard let data = try? Data(contentsOf: settingsURL), let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let hooks = settings["hooks"] as? [String: Any] else { return false } // Track whether any BroV hook is installed at all var hasBroVHooks = false if let permReqHooks = hooks["PermissionRequest"] as? [[String: Any]] { for matcher in permReqHooks { if let hookList = matcher["hooks"] as? [[String: Any]] { for hook in hookList { if let cmd = hook["command"] as? String, cmd.contains("NotchBuddy") || cmd.contains("coucou") { hasBroVHooks = true if let timeout = hook["timeout"] as? Int, timeout < 120 { return true } } } } } } // Check that the AskUserQuestion PreToolUse entry exists if hasBroVHooks { let preToolHooks = hooks["PreToolUse"] as? [[String: Any]] ?? [] let hasAskEntry = preToolHooks.contains { m in (m["matcher"] as? String) == "AskUserQuestion" && (m["hooks"] as? [[String: Any]])?.contains { let cmd = $0["command"] as? String ?? "" return cmd.contains("NotchBuddy") || cmd.contains("coucou") } ?? false } if !hasAskEntry { return true } } return false } // MARK: - Claude Code settings.json hook installer private var _pendingHooksData: Data? /// The bytes of settings.json the pending preview was computed from. private var _pendingHooksOriginal: Data? /// Returns preview JSON without writing — call writeClaudeHooks() to confirm. func previewClaudeHooks() throws -> String { let (data, original) = try buildHooksData() _pendingHooksData = data _pendingHooksOriginal = original return String(data: data, encoding: .utf8) ?? "" } /// Writes the hooks to disk (call after user confirms preview). /// Refused if settings.json changed since the preview, or cannot be backed up. func writeClaudeHooks() throws { guard let data = _pendingHooksData else { return } let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingHooksOriginal) _pendingHooksData = nil _pendingHooksOriginal = nil } private func buildHooksData() throws -> (data: Data, original: Data?) { let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") // Unreadable or invalid settings must stop here, never count as empty. let snapshot = try ClaudeSettingsFile.read(at: settingsURL) var settings = snapshot.object let hookPath = Self.hookScriptPath #if APPSTORE // Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" #else let quotedCmd = "\"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" #endif let events: [(String, Int)] = [ ("SessionStart", 10), ("SessionEnd", 10), ("UserPromptSubmit", 10), ("PreToolUse", 10), ("PostToolUse", 10), ("PostToolUseFailure", 10), ("PermissionRequest", 120), ("Notification", 10), ("Stop", 10), ("StopFailure", 10), ("SubagentStart", 10), ("SubagentStop", 10), ] // "hooks" in a shape we do not know is refused, never replaced. var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json") for (event, timeout) in events { var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json") existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false } existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) hooks[event] = existing } // Dedicated AskUserQuestion PreToolUse hook (Claude Code 2.1.85+, timeout 130s) var preToolUse = hooks["PreToolUse"] as? [[String: Any]] ?? [] preToolUse.append([ "matcher": "AskUserQuestion", "hooks": [["type": "command", "command": "\(quotedCmd) --ask", "timeout": 130]], ]) hooks["PreToolUse"] = preToolUse settings["hooks"] = hooks let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) return (data, snapshot.bytes) } func uninstallClaudeHooks() throws { let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") let snapshot = try ClaudeSettingsFile.read(at: settingsURL) var settings = snapshot.object guard var hooks = settings["hooks"] as? [String: Any] else { return } for key in hooks.keys { if var matchers = hooks[key] as? [[String: Any]] { matchers.removeAll { matcher in (matcher["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false } if matchers.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = matchers } } } settings["hooks"] = hooks let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes) } // MARK: - Claude plan status line installer private var statusLinePreviousURL: URL { Self.supportDir.appendingPathComponent("statusline-previous.json") } /// Returns true if our statusLine command is installed in ~/.claude/settings.json. static func statusLineInstalled() -> Bool { let url = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") guard let data = try? Data(contentsOf: url), let settings = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let sl = settings["statusLine"] as? [String: Any], let cmd = sl["command"] as? String else { return false } return cmd.contains("nb-hook") } private var _pendingStatusLineData: Data? /// The bytes of settings.json the pending preview was computed from. private var _pendingStatusLineOriginal: Data? private var _pendingPreviousData: Data? private var _pendingDeletePrevious: Bool = false /// Returns a diff string (only the statusLine key: before → after) without writing anything. func previewStatusLine(install: Bool) throws -> String { let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") // Unreadable or invalid settings must stop here, never count as empty. let snapshot = try ClaudeSettingsFile.read(at: settingsURL) let settings = snapshot.object let hookPath = Self.hookScriptPath let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") let quotedCmd = "\"\(quotedPath)\" --statusline" // Reset pending side-effects _pendingPreviousData = nil _pendingDeletePrevious = false let oldSL = settings["statusLine"] as? [String: Any] let newSL: [String: Any]? if install { // Check that Python 3 is available (requires Command Line Tools) let clCheck = Process() clCheck.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select") clCheck.arguments = ["-p"] clCheck.standardOutput = FileHandle.nullDevice clCheck.standardError = FileHandle.nullDevice try? clCheck.run() clCheck.waitUntilExit() if clCheck.terminationStatus != 0 { throw NSError(domain: "Coucou", code: 1, userInfo: [NSLocalizedDescriptionKey: "Нужны Command Line Tools, но они не установлены. Выполните: xcode-select --install"]) } if let existing = oldSL, let cmd = existing["command"] as? String, !cmd.contains("nb-hook") { // Keep existing object but swap command; save old for later restoration var updated = existing updated["command"] = quotedCmd newSL = updated _pendingPreviousData = try? JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) } else if let existing = oldSL, let cmd = existing["command"] as? String, cmd.contains("nb-hook") { // Already installed — rebuild to update path if needed, keep other fields var updated = existing updated["command"] = quotedCmd newSL = updated } else { newSL = ["type": "command", "command": quotedCmd] } } else { // Uninstall: only if it's ours if let cur = oldSL, let cmd = cur["command"] as? String, cmd.contains("nb-hook") { if let prevData = try? Data(contentsOf: statusLinePreviousURL), let prevObj = (try? JSONSerialization.jsonObject(with: prevData)) as? [String: Any] { newSL = prevObj _pendingDeletePrevious = true } else { newSL = nil } } else { newSL = oldSL // not ours — leave unchanged } } // Build the full settings.json with the new statusLine var newSettings = settings if let sl = newSL { newSettings["statusLine"] = sl } else { newSettings.removeValue(forKey: "statusLine") } let data = try JSONSerialization.data(withJSONObject: newSettings, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) _pendingStatusLineData = data _pendingStatusLineOriginal = snapshot.bytes // Build a compact diff: show only the statusLine key before → after func slJSON(_ val: [String: Any]?) throws -> String { guard let v = val else { return "(нет)" } let d = try JSONSerialization.data(withJSONObject: v, options: [.prettyPrinted, .sortedKeys]) return String(data: d, encoding: .utf8) ?? "(нет)" } let before = try slJSON(oldSL) let after = try slJSON(newSL) return "statusLine\nБыло:\n\(before)\n\nСтало:\n\(after)" } /// Writes settings.json and commits side effects (call after user confirms). func writeStatusLine() throws { guard let data = _pendingStatusLineData else { return } let settingsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude/settings.json") try ClaudeSettingsFile.write(data, to: settingsURL, expecting: _pendingStatusLineOriginal) // Commit side effects only after successful write if let prevData = _pendingPreviousData { try? prevData.write(to: statusLinePreviousURL, options: .atomic) } if _pendingDeletePrevious { try? FileManager.default.removeItem(at: statusLinePreviousURL) } _pendingStatusLineData = nil _pendingStatusLineOriginal = nil _pendingPreviousData = nil _pendingDeletePrevious = false } // MARK: - App Store: hooks via security-scoped bookmark #if APPSTORE /// Writes nb-hook script and updates settings.json in one shot. /// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed). func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws { let (data, original) = try buildHooksData(claudeURL: claudeURL) // Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/ let coucouDir = claudeURL.appendingPathComponent("coucou") try FileManager.default.createDirectory(at: coucouDir, withIntermediateDirectories: true) let wrapperURL = coucouDir.appendingPathComponent("nb-hook") try nbHookShellWrapper.write(to: wrapperURL, atomically: true, encoding: .utf8) _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: wrapperURL.path) let pyURL = coucouDir.appendingPathComponent("nb-hook.py") try nbHookPythonAppStore.write(to: pyURL, atomically: true, encoding: .utf8) _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: pyURL.path) // Write settings.json (with backup) let settingsURL = claudeURL.appendingPathComponent("settings.json") try ClaudeSettingsFile.write(data, to: settingsURL, expecting: original) UserDefaults.standard.set(true, forKey: "coucouHooksInstalled") } func uninstallClaudeHooksAppStore(claudeURL: URL) throws { let settingsURL = claudeURL.appendingPathComponent("settings.json") let snapshot = try ClaudeSettingsFile.read(at: settingsURL) var settings = snapshot.object guard var hooks = settings["hooks"] as? [String: Any] else { return } for key in hooks.keys { if var matchers = hooks[key] as? [[String: Any]] { matchers.removeAll { matcher in (matcher["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("coucou") == true || ($0["command"] as? String)?.contains("NotchBuddy") == true } ?? false } if matchers.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = matchers } } } settings["hooks"] = hooks let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) try ClaudeSettingsFile.write(newData, to: settingsURL, expecting: snapshot.bytes) UserDefaults.standard.set(false, forKey: "coucouHooksInstalled") } private func buildHooksData(claudeURL: URL) throws -> (data: Data, original: Data?) { let settingsURL = claudeURL.appendingPathComponent("settings.json") // Unreadable or invalid settings must stop here, never count as empty. let snapshot = try ClaudeSettingsFile.read(at: settingsURL) var settings = snapshot.object // Derive hook path from the panel-selected claudeURL (real ~/.claude, not container) let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" let events: [(String, Int)] = [ ("SessionStart", 10), ("SessionEnd", 10), ("UserPromptSubmit", 10), ("PreToolUse", 10), ("PostToolUse", 10), ("PostToolUseFailure", 10), ("PermissionRequest", 120), ("Notification", 10), ("Stop", 10), ("StopFailure", 10), ("SubagentStart", 10), ("SubagentStop", 10), ] // "hooks" in a shape we do not know is refused, never replaced. var hooks = try ClaudeSettingsFile.hooks(in: settings, name: "settings.json") for (event, timeout) in events { var existing = try ClaudeSettingsFile.hookGroups(in: hooks, event: event, name: "settings.json") existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("coucou") == true || ($0["command"] as? String)?.contains("NotchBuddy") == true } ?? false } existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) hooks[event] = existing } // Dedicated AskUserQuestion PreToolUse hook (Claude Code 2.1.85+, timeout 130s) var preToolUse = hooks["PreToolUse"] as? [[String: Any]] ?? [] preToolUse.append([ "matcher": "AskUserQuestion", "hooks": [["type": "command", "command": "\(quotedCmd) --ask", "timeout": 130]], ]) hooks["PreToolUse"] = preToolUse settings["hooks"] = hooks let data = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) return (data, snapshot.bytes) } #endif // MARK: - Gemini CLI and Antigravity hook installers (#if !APPSTORE only) #if !APPSTORE private static var geminiSettingsURL: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gemini/settings.json") } private static var agyHooksURL: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gemini/config/hooks.json") } // MARK: Installed-state detection static func geminiHooksInstalled() -> Bool { guard let data = try? Data(contentsOf: geminiSettingsURL), let settings = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let hooks = settings["hooks"] as? [String: Any] else { return false } for value in hooks.values { guard let groups = value as? [[String: Any]] else { continue } for group in groups { if let innerHooks = group["hooks"] as? [[String: Any]] { for hook in innerHooks { if let cmd = hook["command"] as? String, cmd.contains("nb-hook"), cmd.contains("--agent gemini") { return true } } } // Legacy flat entry if let cmd = group["command"] as? String, cmd.contains("nb-hook"), cmd.contains("--agent gemini") { return true } } } return false } static func agyHooksInstalled() -> Bool { guard let data = try? Data(contentsOf: agyHooksURL), let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let coucou = root["coucou"] else { return false } let json = (try? JSONSerialization.data(withJSONObject: coucou)) .flatMap { String(data: $0, encoding: .utf8) } ?? "" return json.contains("nb-hook") } // MARK: Gemini CLI – preview / write private var _pendingGeminiData: Data? private var _pendingGeminiFingerprint: String? func previewGeminiHooks(install: Bool) throws -> String { let url = Self.geminiSettingsURL let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ NSLocalizedDescriptionKey: "Нет хуков Gemini CLI для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() _pendingGeminiFingerprint = sha256Hex(current) let newData = install ? try buildGeminiHooksData() : try withoutGeminiHooks() _pendingGeminiData = newData return String(data: newData, encoding: .utf8) ?? "" } func writeGeminiHooks() throws { guard let data = _pendingGeminiData, let fp = _pendingGeminiFingerprint else { return } let url = Self.geminiSettingsURL let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ NSLocalizedDescriptionKey: "~/.gemini/settings.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "settings.json") _pendingGeminiData = nil _pendingGeminiFingerprint = nil } private func buildGeminiHooksData() throws -> Data { var settings = try Self.strictReadJSONObject(at: Self.geminiSettingsURL, label: "~/.gemini/settings.json") if let raw = settings["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } let base = hookBase() // (Gemini event key, normalized event name passed via argv, timeout in ms) let events: [(String, String, Int)] = [ ("SessionStart", "SessionStart", 10000), ("SessionEnd", "SessionEnd", 10000), ("BeforeTool", "PreToolUse", 5000), ("AfterTool", "PostToolUse", 5000), ("BeforeAgent", "UserPromptSubmit", 5000), ("AfterAgent", "Stop", 5000), ] var hooks = settings["hooks"] as? [String: Any] ?? [:] for (geminiEvent, normalizedEvent, timeout) in events { if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] имеет неожиданный тип — BroV его не трогал." ]) } var groups = hooks[geminiEvent] as? [[String: Any]] ?? [] // Remove legacy flat entries and groups whose inner hooks contain nb-hook groups = removeNbHookEntries(from: groups) let hookEntry: [String: Any] = [ "type": "command", "command": "\(base) --agent gemini \(normalizedEvent)", "timeout": timeout, ] groups.append(["matcher": "*", "hooks": [hookEntry]]) hooks[geminiEvent] = groups } settings["hooks"] = hooks return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } private func withoutGeminiHooks() throws -> Data { var settings = try Self.strictReadJSONObject(at: Self.geminiSettingsURL, label: "~/.gemini/settings.json") if let raw = settings["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } if var hooks = settings["hooks"] as? [String: Any] { for key in hooks.keys { if let groups = hooks[key] as? [[String: Any]] { let cleaned = removeNbHookEntries(from: groups) if cleaned.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = cleaned } } } if hooks.isEmpty { settings.removeValue(forKey: "hooks") } else { settings["hooks"] = hooks } } return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } // MARK: Antigravity – preview / write private var _pendingAgyData: Data? private var _pendingAgyFingerprint: String? func previewAgyHooks(install: Bool) throws -> String { let url = Self.agyHooksURL let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ NSLocalizedDescriptionKey: "Нет хуков Antigravity для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() _pendingAgyFingerprint = sha256Hex(current) let newData = install ? try buildAgyHooksData() : try withoutAgyHooks() _pendingAgyData = newData return String(data: newData, encoding: .utf8) ?? "" } func writeAgyHooks() throws { guard let data = _pendingAgyData, let fp = _pendingAgyFingerprint else { return } let url = Self.agyHooksURL let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ NSLocalizedDescriptionKey: "~/.gemini/config/hooks.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "hooks.json") _pendingAgyData = nil _pendingAgyFingerprint = nil } private func buildAgyHooksData() throws -> Data { var root = try Self.strictReadJSONObject(at: Self.agyHooksURL, label: "~/.gemini/config/hooks.json") let base = hookBase() // PreToolUse / PostToolUse: tool-level hooks — use matcher group // PreInvocation / PostInvocation / Stop: lifecycle hooks — direct handler, no matcher var coucou: [String: Any] = [:] for event in ["PreToolUse", "PostToolUse"] { let hook: [String: Any] = ["type": "command", "command": "\(base) --agent antigravity \(event)", "timeout": 10] coucou[event] = [["matcher": "*", "hooks": [hook]]] } for event in ["PreInvocation", "PostInvocation", "Stop"] { let hook: [String: Any] = ["type": "command", "command": "\(base) --agent antigravity \(event)", "timeout": 10] coucou[event] = [hook] } root["coucou"] = coucou return try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } private func withoutAgyHooks() throws -> Data { var root = try Self.strictReadJSONObject(at: Self.agyHooksURL, label: "~/.gemini/config/hooks.json") root.removeValue(forKey: "coucou") return try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } // MARK: Shared helpers /// /bin/sh "" — quoted for paths containing spaces (Application Support). private func hookBase() -> String { let path = Self.hookScriptPath.replacingOccurrences(of: "\"", with: "\\\"") return "/bin/sh \"\(path)\"" } /// Reads a JSON object from url. /// Absent file → empty dict. Present but invalid → throws with a user-facing message. private static func strictReadJSONObject(at url: URL, label: String) throws -> [String: Any] { guard FileManager.default.fileExists(atPath: url.path) else { return [:] } let data: Data do { data = try Data(contentsOf: url) } catch { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Не удалось прочитать \(label) — BroV его не трогал." ]) } guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "\(label) — некорректный JSON, BroV его не трогал." ]) } return obj } /// Backs up the existing file (throws on failure), creates parent dirs, then atomically writes. private func writeJSONFile(_ data: Data, to url: URL, suffix: String) throws { let fm = FileManager.default if fm.fileExists(atPath: url.path) { let fmt = DateFormatter() fmt.locale = Locale(identifier: "en_US_POSIX") fmt.dateFormat = "yyyyMMdd-HHmmss" let backupURL = url.deletingLastPathComponent() .appendingPathComponent("\(suffix).bak-\(fmt.string(from: Date()))") do { try fm.copyItem(at: url, to: backupURL) } catch { throw NSError(domain: "Coucou", code: 3, userInfo: [ NSLocalizedDescriptionKey: "Не удалось сделать резервную копию \(url.lastPathComponent): \(error.localizedDescription)" ]) } } try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: .atomic) } /// Removes entries containing "nb-hook" from a Gemini-format groups array. /// Handles both new group format (matcher + hooks[]) and legacy flat format (command at top level). /// Returns the cleaned array; empty groups (after inner-hook removal) are dropped. private func removeNbHookEntries(from groups: [[String: Any]]) -> [[String: Any]] { groups.compactMap { group -> [String: Any]? in // Legacy flat entry — command at group level if let cmd = group["command"] as? String, cmd.contains("nb-hook") { return nil } // Group format — filter inner hooks if var innerHooks = group["hooks"] as? [[String: Any]] { innerHooks.removeAll { ($0["command"] as? String)?.contains("nb-hook") == true } if innerHooks.isEmpty { return nil } var updated = group updated["hooks"] = innerHooks return updated } return group } } // MARK: - Codex hook installer (#if !APPSTORE only) static var codexHooksURL: URL { FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/hooks.json") } /// True when ~/.codex/hooks.json already routes Codex events to BroV's nb-hook. static func codexHooksInstalled() -> Bool { guard let data = try? Data(contentsOf: codexHooksURL), let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let hooks = root["hooks"] as? [String: Any] else { return false } for value in hooks.values { guard let groups = value as? [[String: Any]] else { continue } for group in groups { if let innerHooks = group["hooks"] as? [[String: Any]] { for hook in innerHooks { if let cmd = hook["command"] as? String, cmd.contains("nb-hook"), cmd.contains("--agent codex") { return true } } } } } return false } private var _pendingCodexData: Data? private var _pendingCodexFingerprint: String? func previewCodexHooks(install: Bool) throws -> String { let url = Self.codexHooksURL let exists = FileManager.default.fileExists(atPath: url.path) if !install && !exists { throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ NSLocalizedDescriptionKey: "Нет хуков Codex для удаления." ]) } let current = exists ? try Data(contentsOf: url) : Data() _pendingCodexFingerprint = sha256Hex(current) let newData = install ? try buildCodexHooksData() : try withoutCodexHooks() _pendingCodexData = newData return String(data: newData, encoding: .utf8) ?? "" } func writeCodexHooks() throws { guard let data = _pendingCodexData, let fp = _pendingCodexFingerprint else { return } let url = Self.codexHooksURL let current = (try? Data(contentsOf: url)) ?? Data() guard sha256Hex(current) == fp else { throw NSError(domain: "Coucou", code: 1, userInfo: [ NSLocalizedDescriptionKey: "~/.codex/hooks.json изменился после предпросмотра. Обновите и попробуйте снова." ]) } try writeJSONFile(data, to: url, suffix: "hooks.json") _pendingCodexData = nil _pendingCodexFingerprint = nil } private func buildCodexHooksData() throws -> Data { var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") if let raw = root["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } let base = hookBase() // Events, timeouts in seconds (Codex format). // PermissionRequest uses 120s + a statusMessage shown in the Codex UI while waiting. let events: [(String, Int, String?)] = [ ("SessionStart", 10, nil), ("UserPromptSubmit", 10, nil), ("PreToolUse", 10, nil), ("PermissionRequest", 120, "Waiting for your answer in the notch (BroV)"), ("PostToolUse", 10, nil), ("Stop", 10, nil), ("SubagentStart", 10, nil), ("SubagentStop", 10, nil), ("Interrupt", 3, nil), ("SessionEnd", 3, nil), ] var hooks = root["hooks"] as? [String: Any] ?? [:] for (event, timeout, statusMsg) in events { if let raw = hooks[event], !(raw is [[String: Any]]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] имеет неожиданный тип — BroV его не трогал." ]) } var groups = hooks[event] as? [[String: Any]] ?? [] // Remove existing BroV entries groups = removeNbHookEntries(from: groups) var hookEntry: [String: Any] = [ "type": "command", "command": "\(base) --agent codex", "timeout": timeout, ] if let msg = statusMsg { hookEntry["statusMessage"] = msg } groups.append(["hooks": [hookEntry]]) hooks[event] = groups } root["hooks"] = hooks return try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } private func withoutCodexHooks() throws -> Data { var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") if let raw = root["hooks"], !(raw is [String: Any]) { throw NSError(domain: "Coucou", code: 2, userInfo: [ NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" имеет неожиданный тип — BroV его не трогал." ]) } if var hooks = root["hooks"] as? [String: Any] { for key in hooks.keys { if let groups = hooks[key] as? [[String: Any]] { let cleaned = removeNbHookEntries(from: groups) if cleaned.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = cleaned } } } if hooks.isEmpty { root.removeValue(forKey: "hooks") } else { root["hooks"] = hooks } } return try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) } // MARK: SHA-256 fingerprint private func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } #endif } // MARK: - Notification names for hook server → controller communication extension Notification.Name { static let hookExpand = Notification.Name("notchBuddy.hookExpand") } // MARK: - nb-hook shell wrapper (same for both GitHub and App Store) // Invoked by Claude Code via /bin/sh or directly via shebang. // Always exits 0 — never blocks Claude Code. // Checks xcode-select before running python3 to avoid triggering the // "install developer tools" dialog on machines without Xcode CLI tools. private let nbHookShellWrapper = """ #!/bin/sh # BroV hook relay — always exits 0, never blocks Claude Code HOOK_DIR="$(dirname "$0")" if xcode-select -p >/dev/null 2>&1; then out=$(/usr/bin/python3 "$HOOK_DIR/nb-hook.py" "$@" 2>/dev/null) rc=$? if [ "$rc" -eq 0 ] && [ -n "$out" ]; then printf '%s\\n' "$out" fi fi exit 0 """ // MARK: - nb-hook Python relay (GitHub / non-sandboxed version) private let nbHookPythonGitHub = """ #!/usr/bin/env python3 # nb-hook.py — BroV hook relay for Claude Code and third-party agents (GitHub version) # Reads JSON from stdin, forwards to BroV via Unix socket, translates response. import sys, json, os, socket def normalize_event(name): mapping = { 'BeforeTool': 'PreToolUse', 'BeforeToolSelection': 'PreToolUse', 'AfterTool': 'PostToolUse', 'AfterModel': 'PostToolUse', 'BeforeAgent': 'UserPromptSubmit', 'AfterAgent': 'Stop', 'startup': 'SessionStart', 'exit': 'SessionEnd', 'PreInvocation': 'UserPromptSubmit', 'PostInvocation': 'PostToolUse', } return mapping.get(name, name) def normalize_tool_fields(payload): if 'tool_name' in payload: return tool = payload.get('toolCall') if not isinstance(tool, dict): tool = {} name = tool.get('name') or payload.get('tool', '') if name: payload['tool_name'] = name if 'tool_input' not in payload and isinstance(tool.get('args'), dict): flat = dict(tool['args']) for src, dst in [('CommandLine', 'command'), ('FilePath', 'file_path'), ('Path', 'path'), ('Url', 'url'), ('Query', 'query'), ('Pattern', 'pattern')]: if src in flat: flat[dst] = flat[src] payload['tool_input'] = flat if 'session_id' not in payload: for k in ['conversationId', 'conversation_id', 'sessionId', 'GEMINI_SESSION_ID']: if payload.get(k): payload['session_id'] = payload[k] break if 'session_id' not in payload: sid = os.environ.get('GEMINI_SESSION_ID', '') if sid: payload['session_id'] = sid def main(): raw = b'' payload = {} try: raw = sys.stdin.buffer.read() if not raw: if '--statusline' not in sys.argv[1:]: return else: payload = json.loads(raw) except Exception: if '--statusline' not in sys.argv[1:]: return socket_path = os.path.expanduser( '~/Library/Application Support/NotchBuddy/nb.sock' ) # --statusline mode: relay rate_limits to BroV, then delegate to saved previous if '--statusline' in sys.argv[1:]: relay = { 'coucou_kind': 'statusline', 'session_id': payload.get('session_id', ''), 'rate_limits': payload.get('rate_limits', {}), } try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(0.3) s.connect(socket_path) s.sendall((json.dumps(relay) + '\\n').encode()) s.close() except Exception: pass prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') if os.path.exists(prev_file): try: import subprocess with open(prev_file) as f: prev = json.load(f) cmd = prev.get('command', '') if cmd: result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, capture_output=True, timeout=10) if result.stdout: sys.stdout.buffer.write(result.stdout) sys.stdout.buffer.flush() except Exception: pass return # --ask mode: dedicated hook for AskUserQuestion via PreToolUse (Claude Code 2.1.85+) if '--ask' in sys.argv[1:]: tool = payload.get('tool_name', '') if tool != 'AskUserQuestion': return # Not an AskUserQuestion invocation — exit cleanly (no output) payload['coucou_kind'] = 'ask_user_question' env = os.environ payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) if 'cwd' not in payload or not payload['cwd']: paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) if isinstance(paths, list) and paths: payload['cwd'] = paths[0] else: payload['cwd'] = os.getcwd() try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(125) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) chunks = [] while True: chunk = s.recv(4096) if not chunk: break chunks.append(chunk) if b'\\n' in chunk: break s.close() response = b''.join(chunks).decode().strip() if response: try: resp_obj = json.loads(response) decision = resp_obj.get('permissionDecision', '') except Exception: decision = '' if decision == 'answer': answers = resp_obj.get('answers', {}) questions = payload.get('tool_input', {}).get('questions', []) out = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) # 'ask' or unknown: fall through → no output → Claude Code asks in terminal except Exception: pass return # Parse --agent and optional positional event from argv. # --agent tags the payload with coucou_agent so the app routes to the right pill. # The positional arg is a fallback event name for agents that do not set hook_event_name. args = sys.argv[1:] agent = '' arg_event = '' i = 0 while i < len(args): if args[i] == '--agent' and i + 1 < len(args): agent = args[i + 1] i += 2 else: if not arg_event: arg_event = args[i] i += 1 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', '')) payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) if 'cwd' not in payload or not payload['cwd']: paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) if isinstance(paths, list) and paths: payload['cwd'] = paths[0] else: payload['cwd'] = os.getcwd() # Normalize event name and tool fields (Gemini CLI / Antigravity → canonical names) try: raw_event = payload.get('hook_event_name', '') or arg_event if raw_event: payload['hook_event_name'] = normalize_event(raw_event) normalize_tool_fields(payload) except Exception: pass event = payload.get('hook_event_name', '') # socket_path is already defined above if event == 'PermissionRequest': # Block and wait for BroV's decision (Claude Code allows up to 120s) try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(118) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) chunks = [] while True: chunk = s.recv(4096) if not chunk: break chunks.append(chunk) if b'\\n' in chunk: break s.close() response = b''.join(chunks).decode().strip() if response: try: resp_obj = json.loads(response) decision = resp_obj.get('permissionDecision', '') except Exception: decision = '' if decision == 'allow': out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'always' and agent != 'codex': # Let Claude Code persist the rule via updatedPermissions suggestions = payload.get('permission_suggestions', []) out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedPermissions': suggestions}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'always': # Codex rejects updatedPermissions — answer a plain allow instead out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'deny': out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'answer': # AskUserQuestion answered from the notch answers = resp_obj.get('answers', {}) questions = payload.get('tool_input', {}).get('questions', []) out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) # 'ask' or unknown: fall through → no output → agent re-asks except Exception: pass # App unreachable, timed out, or no explicit decision — print nothing # Claude Code / Codex will handle the absence of output (re-ask or default behaviour) sys.exit(0) # All other events: fire-and-forget (0.3s timeout, never blocks) try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(0.3) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) s.close() except Exception: pass # Always exit cleanly — never block the agent # Gemini CLI and Antigravity expect a JSON response on stdout (empty = no decision) if agent in ('gemini', 'antigravity'): sys.stdout.write('{}\\n') sys.stdout.flush() main() sys.exit(0) """ // MARK: - nb-hook Python relay (App Store — socket in sandboxed container) private let nbHookPythonAppStore = """ #!/usr/bin/env python3 # nb-hook.py — BroV (App Store) hook relay for Claude Code and third-party agents # Socket lives inside the sandboxed container; script runs outside the sandbox. import sys, json, os, socket def normalize_event(name): mapping = { 'BeforeTool': 'PreToolUse', 'BeforeToolSelection': 'PreToolUse', 'AfterTool': 'PostToolUse', 'AfterModel': 'PostToolUse', 'BeforeAgent': 'UserPromptSubmit', 'AfterAgent': 'Stop', 'startup': 'SessionStart', 'exit': 'SessionEnd', 'PreInvocation': 'UserPromptSubmit', 'PostInvocation': 'PostToolUse', } return mapping.get(name, name) def normalize_tool_fields(payload): if 'tool_name' in payload: return tool = payload.get('toolCall') if not isinstance(tool, dict): tool = {} name = tool.get('name') or payload.get('tool', '') if name: payload['tool_name'] = name if 'tool_input' not in payload and isinstance(tool.get('args'), dict): flat = dict(tool['args']) for src, dst in [('CommandLine', 'command'), ('FilePath', 'file_path'), ('Path', 'path'), ('Url', 'url'), ('Query', 'query'), ('Pattern', 'pattern')]: if src in flat: flat[dst] = flat[src] payload['tool_input'] = flat if 'session_id' not in payload: for k in ['conversationId', 'conversation_id', 'sessionId', 'GEMINI_SESSION_ID']: if payload.get(k): payload['session_id'] = payload[k] break if 'session_id' not in payload: sid = os.environ.get('GEMINI_SESSION_ID', '') if sid: payload['session_id'] = sid def main(): raw = b'' payload = {} try: raw = sys.stdin.buffer.read() if not raw: if '--statusline' not in sys.argv[1:]: return else: payload = json.loads(raw) except Exception: if '--statusline' not in sys.argv[1:]: return socket_path = os.path.expanduser( '~/Library/Containers/fr.louisraille.BroV/Data/nb.sock' ) # --statusline mode: relay rate_limits to BroV, then delegate to saved previous if '--statusline' in sys.argv[1:]: relay = { 'coucou_kind': 'statusline', 'session_id': payload.get('session_id', ''), 'rate_limits': payload.get('rate_limits', {}), } try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(0.3) s.connect(socket_path) s.sendall((json.dumps(relay) + '\\n').encode()) s.close() except Exception: pass prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') if os.path.exists(prev_file): try: import subprocess with open(prev_file) as f: prev = json.load(f) cmd = prev.get('command', '') if cmd: result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, capture_output=True, timeout=10) if result.stdout: sys.stdout.buffer.write(result.stdout) sys.stdout.buffer.flush() except Exception: pass return # --ask mode: dedicated hook for AskUserQuestion via PreToolUse (Claude Code 2.1.85+) if '--ask' in sys.argv[1:]: tool = payload.get('tool_name', '') if tool != 'AskUserQuestion': return # Not an AskUserQuestion invocation — exit cleanly (no output) payload['coucou_kind'] = 'ask_user_question' env = os.environ payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) if 'cwd' not in payload or not payload['cwd']: paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) if isinstance(paths, list) and paths: payload['cwd'] = paths[0] else: payload['cwd'] = os.getcwd() try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(125) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) chunks = [] while True: chunk = s.recv(4096) if not chunk: break chunks.append(chunk) if b'\\n' in chunk: break s.close() response = b''.join(chunks).decode().strip() if response: try: resp_obj = json.loads(response) decision = resp_obj.get('permissionDecision', '') except Exception: decision = '' if decision == 'answer': answers = resp_obj.get('answers', {}) questions = payload.get('tool_input', {}).get('questions', []) out = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) # 'ask' or unknown: fall through → no output → Claude Code asks in terminal except Exception: pass return # Parse --agent and optional positional event from argv. # --agent tags the payload with coucou_agent so the app routes to the right pill. # The positional arg is a fallback event name for agents that do not set hook_event_name. args = sys.argv[1:] agent = '' arg_event = '' i = 0 while i < len(args): if args[i] == '--agent' and i + 1 < len(args): agent = args[i + 1] i += 2 else: if not arg_event: arg_event = args[i] i += 1 if agent: payload.setdefault('coucou_agent', agent) env = os.environ payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) if 'cwd' not in payload or not payload['cwd']: paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) if isinstance(paths, list) and paths: payload['cwd'] = paths[0] else: payload['cwd'] = os.getcwd() # Normalize event name and tool fields (Gemini CLI / Antigravity → canonical names) try: raw_event = payload.get('hook_event_name', '') or arg_event if raw_event: payload['hook_event_name'] = normalize_event(raw_event) normalize_tool_fields(payload) except Exception: pass event = payload.get('hook_event_name', '') # socket_path is already defined above if event == 'PermissionRequest': # Block and wait for BroV's decision (Claude Code allows up to 120s) try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(118) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) chunks = [] while True: chunk = s.recv(4096) if not chunk: break chunks.append(chunk) if b'\\n' in chunk: break s.close() response = b''.join(chunks).decode().strip() if response: try: resp_obj = json.loads(response) decision = resp_obj.get('permissionDecision', '') except Exception: decision = '' if decision == 'allow': out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'always' and agent != 'codex': # Let Claude Code persist the rule via updatedPermissions suggestions = payload.get('permission_suggestions', []) out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedPermissions': suggestions}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'always': # Codex rejects updatedPermissions — answer a plain allow instead out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'deny': out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) elif decision == 'answer': # AskUserQuestion answered from the notch answers = resp_obj.get('answers', {}) questions = payload.get('tool_input', {}).get('questions', []) out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}}} sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.flush() sys.exit(0) # 'ask' or unknown: fall through → no output → agent re-asks except Exception: pass # App unreachable, timed out, or no explicit decision — print nothing sys.exit(0) try: s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(0.3) s.connect(socket_path) s.sendall((json.dumps(payload) + '\\n').encode()) s.close() except Exception: pass # Always exit cleanly — never block the agent # Gemini CLI and Antigravity expect a JSON response on stdout (empty = no decision) if agent in ('gemini', 'antigravity'): sys.stdout.write('{}\\n') sys.stdout.flush() main() sys.exit(0) """