52e1e8288b
- Network core moves to a root-only folder; BroV talks to a narrow root helper (netctl.py) over a user-only socket; install script verifies the mihomo SHA256 and migrates keys (scripts/netcore) - Hardened runtime, no get-task-allow; bypassPermissions removed from the chat; concealed clipboard items are not restored; DangerCheck knows core, LaunchAgents and hook paths; dropped-file copies expire after 7 days - Ported from upstream Coucou: 1h crash fix (d05f22b), safe settings.json writes (918d30e), Escape/fold for pending approvals (6012900, 40e3ba8), auto-close delay + reopen (74984f2, ea244a7), full AskUserQuestion (52b1562) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2446 lines
108 KiB
Swift
2446 lines
108 KiB
Swift
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<sockaddr_un>.size - MemoryLayout<sa_family_t>.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<sockaddr_un>.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<timeval>.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..<n {
|
||
if buf[i] == UInt8(ascii: "\n") { break outer }
|
||
raw.append(buf[i])
|
||
}
|
||
if raw.count > 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_<agent>" 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_<name>" — 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 "<hookScriptPath>" — 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 <name> 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 <name> 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)
|
||
"""
|