From 9610395c33b4443146d380f92a750bc86081c881 Mon Sep 17 00:00:00 2001 From: Evan Zheng <88683151+theMobiusStrip@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:28:05 -0700 Subject: [PATCH] Make monitoring feedback actionable --- Sources/Perch/App/AppDelegate.swift | 22 +- Sources/Perch/App/Selftest.swift | 125 +++++++- Sources/Perch/App/ShowcaseRenderer.swift | 1 + Sources/Perch/Install/Doctor.swift | 39 ++- Sources/Perch/Model/MonitoringHealth.swift | 128 +++++++- Sources/Perch/Model/Notifier.swift | 171 ++++++++++- Sources/Perch/Model/RiskFeed.swift | 16 +- Sources/Perch/Model/SessionStore.swift | 26 +- .../Perch/UI/MonitoringHealthBadgeView.swift | 22 +- Sources/Perch/UI/NotchController.swift | 9 +- Sources/Perch/UI/NotchRootView.swift | 31 +- Sources/Perch/UI/PillView.swift | 25 ++ Sources/Perch/UI/RecentDetectionsWindow.swift | 51 +++- Sources/Perch/UI/SessionRowView.swift | 12 +- Sources/Perch/UI/SetupWindowController.swift | 275 +++++++++++++----- Sources/Perch/UI/StatusItemController.swift | 20 +- Sources/PerchCore/PerchConfig.swift | 21 ++ 17 files changed, 856 insertions(+), 138 deletions(-) diff --git a/Sources/Perch/App/AppDelegate.swift b/Sources/Perch/App/AppDelegate.swift index 2809fca..e00b6ac 100644 --- a/Sources/Perch/App/AppDelegate.swift +++ b/Sources/Perch/App/AppDelegate.swift @@ -86,7 +86,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let health = monitoringHealth let server = UnixSocketServer { envelope, reply in Task { @MainActor in - health.noteEvent() + if envelope.kind == .hook { + health.noteEvent(agent: envelope.agent) + } store.handleEnvelope(envelope, reply: reply) } } @@ -209,6 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + monitoringHealth.persistVerification() socketServer?.stop() PerchLog.info("Perch terminated") } @@ -226,10 +229,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sessionStore.onTaskComplete = { [weak self] session, message in self?.notifier?.notifyTaskComplete(session: session, message: message) } - sessionStore.onRiskDetected = { [weak self] session, toolName, risk in + sessionStore.onRiskDetected = { [weak self] session, entry in self?.attentionPending = true self?.notch?.attention() - self?.notifier?.notifyRisk(session: session, toolName: toolName, risk: risk) + self?.notifier?.notifyRisk(session: session, entry: entry) } riskFeed.onEmpty = { [weak self] in self?.clearNotchAttention() @@ -248,6 +251,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { usageStore.onThreshold = { [weak self] label, pct in self?.notifier?.notifyUsageThreshold(label: label, pct: pct) } + notifier?.onOpenDetections = { [weak self] id in + self?.openRecentDetectionsWindow(focusing: id) + } + notifier?.onOpenSessions = { [weak self] key in + self?.notch?.expand(focusing: key) + } + notifier?.onOpenUsage = { [weak self] in + self?.openUsageHistoryWindow() + } } private func sessionsDidPublish(_ sessions: [Session]) { @@ -305,12 +317,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { setupWindow?.show(runDoctor: runDoctor) } - private func openRecentDetectionsWindow() { + private func openRecentDetectionsWindow(focusing id: UUID? = nil) { if recentDetectionsWindow == nil { recentDetectionsWindow = RecentDetectionsWindowController( feed: riskFeed, posture: securityPosture) } - recentDetectionsWindow?.show() + recentDetectionsWindow?.show(focusing: id) } private func refreshMonitoringHealth() { diff --git a/Sources/Perch/App/Selftest.swift b/Sources/Perch/App/Selftest.swift index 13518aa..6bebd15 100644 --- a/Sources/Perch/App/Selftest.swift +++ b/Sources/Perch/App/Selftest.swift @@ -31,6 +31,7 @@ enum Selftest { perchConfigCheckForUpdatesRoundTrip(t) perchConfigWorktreeStaleDaysRoundTrip(t) perchConfigNotificationPreferencesRoundTrip(t) + perchConfigMonitoringVerificationRoundTrip(t) semVerParsesAndCompares(t) updateCheckDecision(t) @@ -48,6 +49,9 @@ enum Selftest { riskFeedDismissAndFocusClamp(t) riskFeedRetainsRecentDetections(t) monitoringSnapshotSeparatesCoverageFromPosture(t) + monitoringHealthSeparatesConfigurationFromVerification(t) + notificationCoalescerSuppressesOnlyOverlap(t) + doctorStructuredOutcomeReflectsVisibleChecks(t) sessionRiskBadgeAgesOut(t) handleEnvelopeRoutesUserPromptSubmit(t) handleEnvelopeRoutesStop(t) @@ -727,10 +731,14 @@ private extension Selftest { feed.onAdd = { _ in added += 1 } let key = SessionKey(agent: .claude, id: "s-1") let dangerInput = JSONValue.object(["command": .string("sudo rm -rf /")]) - feed.add(key: key, toolName: "Bash", toolInput: dangerInput, cwd: "/tmp/proj", - risk: RiskAssessor.assess(agent: .claude, toolName: "Bash", input: dangerInput)) + let entry = feed.addEntry( + key: key, toolName: "Bash", toolInput: dangerInput, cwd: "/tmp/proj", + risk: RiskAssessor.assess(agent: .claude, toolName: "Bash", input: dangerInput)) + t.expectTrue(entry != nil, "entryReturnedForRouting") t.expectEqual(feed.count, 1, "dangerAdded") t.expectEqual(added, 1, "onAddFired") + t.expectEqual(feed.focused?.id, entry?.id, "returnedEntryMatchesFocused") + t.expectEqual(feed.recent.first?.id, entry?.id, "returnedEntryMatchesHistory") t.expectEqual(feed.focused?.risk.level, .danger, "focusedDanger") t.expectEqual(feed.focused?.cwd, "/tmp/proj", "cwdCarried") @@ -880,7 +888,9 @@ private extension Selftest { var attentions: [String] = [] store.onAttention = { _, reason in attentions.append(reason) } var risks: [String] = [] - store.onRiskDetected = { _, toolName, risk in risks.append("\(risk.level.label):\(toolName)") } + store.onRiskDetected = { _, entry in + risks.append("\(entry.risk.level.label):\(entry.toolName)") + } let recorder = ReplyRecorder() // A benign read: replied immediately and empty, nothing in the feed. @@ -924,8 +934,8 @@ private extension Selftest { let feed = RiskFeed() store.riskFeed = feed var risks: [String] = [] - store.onRiskDetected = { session, toolName, risk in - risks.append("\(session.key.id)|\(toolName)|\(risk.level.label)") + store.onRiskDetected = { session, entry in + risks.append("\(session.key.id)|\(entry.toolName)|\(entry.risk.level.label)") } let recorder = ReplyRecorder() store.handleEnvelope(hookEnvelope(event: "PreToolUse", extra: [ @@ -962,7 +972,7 @@ private extension Selftest { store.riskFeed = feed store.securityPosture = posture var riskCallbacks = 0 - store.onRiskDetected = { _, _, _ in riskCallbacks += 1 } + store.onRiskDetected = { _, _ in riskCallbacks += 1 } // One dangerous call fires BOTH PermissionRequest and PreToolUse; // score, feed, and notification must all count it exactly once. @@ -1174,6 +1184,31 @@ private extension Selftest { t.expectFalse(defaults.hasCompletedSetup, "setupDefaultsIncomplete") } + @MainActor + static func perchConfigMonitoringVerificationRoundTrip(_ t: Checker) { + t.suite("PerchConfig.monitoringVerificationRoundTrip") + let claudeAt = Date(timeIntervalSince1970: 1_900_000_000.25) + let codexAt = Date(timeIntervalSince1970: 1_900_000_100.5) + var config = PerchConfig() + config.lastClaudeHookEventAt = claudeAt + config.lastCodexHookEventAt = codexAt + config.extra["future"] = .string("kept") + + guard let encoded = t.unwrap(try? JSONEncoder().encode(config), "encode"), + let decoded = t.unwrap( + try? JSONDecoder().decode(PerchConfig.self, from: encoded), + "decode") else { return } + t.expectEqual(decoded.lastClaudeHookEventAt, claudeAt, "claudeTimestamp") + t.expectEqual(decoded.lastCodexHookEventAt, codexAt, "codexTimestamp") + t.expectEqual(decoded.extra["future"], .string("kept"), "unknownPreserved") + + guard let defaults = t.unwrap( + try? JSONDecoder().decode(PerchConfig.self, from: Data("{}".utf8)), + "decodeDefaults") else { return } + t.expectNil(defaults.lastClaudeHookEventAt, "claudeDefaultsUnverified") + t.expectNil(defaults.lastCodexHookEventAt, "codexDefaultsUnverified") + } + @MainActor static func riskFeedRetainsRecentDetections(_ t: Checker) { t.suite("RiskFeed.retainsRecentDetections") @@ -1221,6 +1256,84 @@ private extension Selftest { t.expectEqual(runtimeDown.state, .unavailable, "runtimeFailureWins") } + @MainActor + static func monitoringHealthSeparatesConfigurationFromVerification(_ t: Checker) { + t.suite("MonitoringHealth.deliveryVerification") + func check(_ title: String, _ state: MonitoringCheckState) -> MonitoringCheck { + MonitoringCheck(title: title, state: state, summary: title, detail: nil) + } + let configured = MonitoringSnapshot( + bridge: check("Bridge", .ready), socket: check("Runtime", .ready), + claude: check("Claude Code", .ready), codex: check("Codex", .ready)) + let health = MonitoringHealth(config: PerchConfig()) + health.injectSnapshot(configured) + t.expectEqual(health.presentation.state, .needsAttention, + "configurationAloneNeedsVerification") + t.expectEqual(health.presentation.title, "Verification needed", "unverifiedTitle") + + let now = Date(timeIntervalSince1970: 1_900_000_000) + health.injectVerification(claude: now, codex: nil) + t.expectEqual(health.presentation.state, .needsAttention, "partialVerificationIsAmber") + t.expectTrue(health.presentation.summary.contains("Codex"), "missingAgentNamed") + t.expectEqual(health.verificationState(for: .claude), .ready, "claudeVerified") + t.expectEqual(health.verificationState(for: .codex), .needsAttention, + "codexStillUnverified") + + health.injectVerification(claude: now, codex: now.addingTimeInterval(1)) + t.expectEqual(health.presentation.state, .ready, "bothVerified") + t.expectEqual(health.presentation.title, "Monitoring verified", "verifiedTitle") + t.expectEqual(health.lastEventAt, now.addingTimeInterval(1), "newestEventExposed") + + let claudeOnly = MonitoringSnapshot( + bridge: check("Bridge", .ready), socket: check("Runtime", .ready), + claude: check("Claude Code", .ready), codex: check("Codex", .needsAttention)) + health.injectSnapshot(claudeOnly) + health.injectVerification(claude: now, codex: nil) + t.expectEqual(health.presentation.state, .ready, "configuredAgentVerified") + } + + @MainActor + static func notificationCoalescerSuppressesOnlyOverlap(_ t: Checker) { + t.suite("NotificationCoalescer.overlap") + let key = SessionKey(agent: .claude, id: "same") + let other = SessionKey(agent: .claude, id: "other") + let now = Date(timeIntervalSince1970: 1_900_000_000) + var coalescer = NotificationCoalescer() + + t.expectFalse(coalescer.shouldSuppressAttention(for: key, at: now), + "attentionWithoutRiskFires") + coalescer.recordRisk(for: key, at: now) + t.expectTrue(coalescer.shouldSuppressAttention( + for: key, at: now.addingTimeInterval(NotificationCoalescer.overlapWindow)), + "sameSessionOverlapSuppressed") + t.expectFalse(coalescer.shouldSuppressAttention( + for: other, at: now.addingTimeInterval(1)), "otherSessionStillFires") + t.expectFalse(coalescer.shouldSuppressAttention( + for: key, + at: now.addingTimeInterval(NotificationCoalescer.overlapWindow + 0.01)), + "laterAttentionFires") + } + + @MainActor + static func doctorStructuredOutcomeReflectsVisibleChecks(_ t: Checker) { + t.suite("Doctor.structuredOutcome") + func check(_ state: MonitoringCheckState) -> MonitoringCheck { + MonitoringCheck(title: state.rawValue, state: state, + summary: state.rawValue, detail: nil) + } + t.expectEqual(Doctor.aggregateState(for: [check(.ready), check(.ready)]), + .ready, "allReadyIsSuccess") + t.expectEqual(Doctor.aggregateState(for: [check(.ready), check(.needsAttention)]), + .needsAttention, "visibleWarningPreventsGreenHeader") + t.expectEqual(Doctor.aggregateState(for: [check(.needsAttention), check(.unavailable)]), + .unavailable, "failureWins") + t.expectEqual(SetupViewModel.outcome(for: .ready), .success, "readyMapsToSuccess") + t.expectEqual(SetupViewModel.outcome(for: .needsAttention), .attention, + "warningMapsToAttention") + t.expectEqual(SetupViewModel.outcome(for: .unavailable), .failure, + "unavailableMapsToFailure") + } + @MainActor static func sessionRiskBadgeAgesOut(_ t: Checker) { t.suite("Session.riskBadgeAging") diff --git a/Sources/Perch/App/ShowcaseRenderer.swift b/Sources/Perch/App/ShowcaseRenderer.swift index 229b83f..90d3eeb 100644 --- a/Sources/Perch/App/ShowcaseRenderer.swift +++ b/Sources/Perch/App/ShowcaseRenderer.swift @@ -200,6 +200,7 @@ enum ShowcaseRenderer { socket: ready("Runtime", "Local event server is listening"), claude: ready("Claude Code", "Hooks installed"), codex: ready("Codex", "Hooks installed and trusted"))) + health.injectVerification(claude: now, codex: now) state.isExpanded = true state.hasAttention = true state.hasNotch = true diff --git a/Sources/Perch/Install/Doctor.swift b/Sources/Perch/Install/Doctor.swift index 41c5cd0..73271da 100644 --- a/Sources/Perch/Install/Doctor.swift +++ b/Sources/Perch/Install/Doctor.swift @@ -1,18 +1,28 @@ import Foundation import PerchCore +struct DoctorReport: Sendable { + let state: MonitoringCheckState + let checks: [MonitoringCheck] + let text: String +} + /// Aggregated integration health check: bridge deployed? socket live? /// Claude/Codex hook status, Codex version + trust reminder. Rendered as /// plain monospaced text in the menu-bar alert and by `Perch --doctor`. enum Doctor { static func report() -> String { + diagnose().text + } + + static func diagnose() -> DoctorReport { + let runtime = socketCheck() + let snapshot = MonitoringInspector.inspect(runtime: runtime) + let checks = [snapshot.bridge, snapshot.socket, snapshot.claude, snapshot.codex] var lines: [String] = [] lines.append("Perch Doctor — \(AppVersion.string) — \(timestamp())") lines.append("") - lines.append(render(bridgeCheck())) - lines.append(render(socketCheck())) - lines.append(ClaudeHookInstaller.status()) - lines.append(CodexHookInstaller.status()) + lines.append(contentsOf: checks.map(render)) lines.append(CodexHookInstaller.versionSupportNote()) lines.append("Detection: every tool call relayed by the hooks above is risk-scored offline; " + "danger-level calls (rm -rf, sudo, curl|sh, credential access, agent hook/settings writes) raise an OS notification " @@ -25,7 +35,18 @@ enum Doctor { lines.append(CodexHookTrust.doctorLine() + " If Codex hooks are installed but tool calls never surface, missing trust is why.") lines.append("Log: \(PerchPaths.logFile.path)") - return lines.joined(separator: "\n") + return DoctorReport(state: aggregateState(for: checks), checks: checks, + text: lines.joined(separator: "\n")) + } + + /// Doctor is stricter than the compact monitoring badge: a mixed report + /// must not render a green success header while one of its visible checks + /// says that setup or repair is still required. + static func aggregateState(for checks: [MonitoringCheck]) -> MonitoringCheckState { + if checks.contains(where: { $0.state == .unavailable }) { return .unavailable } + if checks.contains(where: { $0.state == .checking }) { return .checking } + if checks.contains(where: { $0.state == .needsAttention }) { return .needsAttention } + return .ready } // MARK: - Checks @@ -116,7 +137,13 @@ enum Doctor { } private static func render(_ check: MonitoringCheck) -> String { - let status = check.isReady ? "OK" : "NEEDS ATTENTION" + let status: String + switch check.state { + case .checking: status = "CHECKING" + case .ready: status = "OK" + case .needsAttention: status = "NEEDS ATTENTION" + case .unavailable: status = "UNAVAILABLE" + } let detail = check.detail.map { " — \($0)" } ?? "" return "\(check.title): \(status) — \(check.summary)\(detail)" } diff --git a/Sources/Perch/Model/MonitoringHealth.swift b/Sources/Perch/Model/MonitoringHealth.swift index aafd451..5443b9e 100644 --- a/Sources/Perch/Model/MonitoringHealth.swift +++ b/Sources/Perch/Model/MonitoringHealth.swift @@ -61,7 +61,7 @@ struct MonitoringSnapshot: Sendable { var title: String { switch state { case .checking: return "Checking monitoring" - case .ready: return "Monitoring active" + case .ready: return "Monitoring configured" case .needsAttention: return "Setup needed" case .unavailable: return "Monitoring offline" } @@ -78,6 +78,23 @@ struct MonitoringSnapshot: Sendable { } var hasConfiguredAgent: Bool { claude.isReady || codex.isReady } + + func check(for agent: AgentKind) -> MonitoringCheck { + switch agent { + case .claude: return claude + case .codex: return codex + } + } + + var configuredAgents: [AgentKind] { + AgentKind.allCases.filter { check(for: $0).isReady } + } +} + +struct MonitoringPresentation: Sendable { + let state: MonitoringCheckState + let title: String + let summary: String } enum MonitoringInspector { @@ -128,9 +145,50 @@ enum MonitoringInspector { final class MonitoringHealth: ObservableObject { @Published private(set) var snapshot: MonitoringSnapshot = .checking @Published private(set) var notificationState: NotificationAuthorizationState = .unknown - @Published private(set) var lastEventAt: Date? + @Published private(set) var lastClaudeEventAt: Date? + @Published private(set) var lastCodexEventAt: Date? @Published private(set) var isRefreshing = false private var runtimeCheck = MonitoringSnapshot.checking.socket + private var lastPersistedClaudeEventAt: Date? + private var lastPersistedCodexEventAt: Date? + + /// Hook events can be frequent; persist the first observation immediately, + /// then at most once per minute per integration. + private static let persistenceInterval: TimeInterval = 60 + + init(config: PerchConfig = .load()) { + lastClaudeEventAt = config.lastClaudeHookEventAt + lastCodexEventAt = config.lastCodexHookEventAt + lastPersistedClaudeEventAt = config.lastClaudeHookEventAt + lastPersistedCodexEventAt = config.lastCodexHookEventAt + } + + var presentation: MonitoringPresentation { + let baseState = snapshot.state + guard baseState == .ready else { + return MonitoringPresentation(state: baseState, title: snapshot.title, + summary: snapshot.summary) + } + + let unverified = snapshot.configuredAgents.filter { lastEventAt(for: $0) == nil } + guard unverified.isEmpty else { + let names = unverified.map(Self.agentName).joined(separator: " and ") + let summary = unverified.count == snapshot.configuredAgents.count + ? "Start or restart \(names) to verify event delivery" + : "\(names) still awaiting a hook event" + return MonitoringPresentation(state: .needsAttention, + title: "Verification needed", summary: summary) + } + + let names = snapshot.configuredAgents.map(Self.agentName) + let covered = names.count == 2 ? names.joined(separator: " and ") : names[0] + return MonitoringPresentation(state: .ready, title: "Monitoring verified", + summary: "\(covered) event delivery verified") + } + + var lastEventAt: Date? { + [lastClaudeEventAt, lastCodexEventAt].compactMap { $0 }.max() + } func refresh() { guard !isRefreshing else { return } @@ -146,8 +204,53 @@ final class MonitoringHealth: ObservableObject { } } - func noteEvent(at date: Date = Date()) { - lastEventAt = date + func noteEvent(agent: AgentKind, at date: Date = Date()) { + switch agent { + case .claude: lastClaudeEventAt = date + case .codex: lastCodexEventAt = date + } + + let persisted = persistedEventAt(for: agent) + if persisted.map({ date.timeIntervalSince($0) >= Self.persistenceInterval }) ?? true { + persistVerification() + } + } + + func lastEventAt(for agent: AgentKind) -> Date? { + switch agent { + case .claude: return lastClaudeEventAt + case .codex: return lastCodexEventAt + } + } + + func verificationState(for agent: AgentKind) -> MonitoringCheckState { + let check = snapshot.check(for: agent) + guard check.isReady else { return check.state } + return lastEventAt(for: agent) == nil ? .needsAttention : .ready + } + + /// Installing, repairing, or removing hooks invalidates an older delivery + /// proof. The next real event must verify the newly configured path. + func clearVerification(for agent: AgentKind) { + switch agent { + case .claude: lastClaudeEventAt = nil + case .codex: lastCodexEventAt = nil + } + persistVerification() + } + + func persistVerification() { + var config = PerchConfig.load() + config.lastClaudeHookEventAt = lastClaudeEventAt + config.lastCodexHookEventAt = lastCodexEventAt + do { + try config.save() + lastPersistedClaudeEventAt = lastClaudeEventAt + lastPersistedCodexEventAt = lastCodexEventAt + } catch { + PerchLog.warn("Could not save monitoring verification: \(error.localizedDescription)", + category: "config") + } } func updateRuntime(isRunning: Bool, error: String? = nil) { @@ -167,4 +270,21 @@ final class MonitoringHealth: ObservableObject { self.snapshot = snapshot isRefreshing = false } + + /// Deterministic seam for selftests and static showcase rendering. + func injectVerification(claude: Date?, codex: Date?) { + lastClaudeEventAt = claude + lastCodexEventAt = codex + } + + private func persistedEventAt(for agent: AgentKind) -> Date? { + switch agent { + case .claude: return lastPersistedClaudeEventAt + case .codex: return lastPersistedCodexEventAt + } + } + + private static func agentName(_ agent: AgentKind) -> String { + agent == .claude ? "Claude Code" : "Codex" + } } diff --git a/Sources/Perch/Model/Notifier.swift b/Sources/Perch/Model/Notifier.swift index 3c0b3cd..73112dd 100644 --- a/Sources/Perch/Model/Notifier.swift +++ b/Sources/Perch/Model/Notifier.swift @@ -2,6 +2,33 @@ import Foundation import PerchCore import UserNotifications +/// A danger notification and the permission-attention event for that same call +/// arrive back-to-back. Keep the useful danger alert and suppress only the +/// redundant attention banner; if danger alerts are disabled, no risk is +/// recorded here and the attention notification still fires. +struct NotificationCoalescer { + static let overlapWindow: TimeInterval = 5 + private var recentRiskBySession: [SessionKey: Date] = [:] + + mutating func recordRisk(for key: SessionKey, at date: Date = Date()) { + prune(now: date) + recentRiskBySession[key] = date + } + + mutating func shouldSuppressAttention(for key: SessionKey, + at date: Date = Date()) -> Bool { + prune(now: date) + guard let riskAt = recentRiskBySession[key] else { return false } + return date.timeIntervalSince(riskAt) <= Self.overlapWindow + } + + private mutating func prune(now: Date) { + recentRiskBySession = recentRiskBySession.filter { + now.timeIntervalSince($0.value) <= Self.overlapWindow + } + } +} + enum NotificationAuthorizationState: String, Sendable { case unavailable case notRequested @@ -31,14 +58,37 @@ enum NotificationAuthorizationState: String, Sendable { /// call is therefore gated behind a bundle check; outside a bundle we fall /// back to PerchLog only. @MainActor -final class Notifier { +final class Notifier: NSObject, UNUserNotificationCenterDelegate { + private enum Route: String { + case detections + case sessions + case usage + } + + private enum NotificationID { + static let riskCategory = "PERCH_RISK" + static let sessionCategory = "PERCH_SESSION" + static let usageCategory = "PERCH_USAGE" + static let openDetections = "PERCH_OPEN_DETECTIONS" + static let openSessions = "PERCH_OPEN_SESSIONS" + static let openUsage = "PERCH_OPEN_USAGE" + static let routeKey = "perchRoute" + static let agentKey = "perchAgent" + static let sessionKey = "perchSession" + static let detectionKey = "perchDetection" + } + private let sessions: SessionStore private let preferences: NotificationPreferences + var onOpenDetections: ((UUID?) -> Void)? + var onOpenSessions: ((SessionKey?) -> Void)? + var onOpenUsage: (() -> Void)? /// Dedupe: fingerprint → last fired. Same fingerprint within /// `dedupeWindow` is skipped. private var recentlyFired: [String: Date] = [:] private let dedupeWindow: TimeInterval = 30 + private var coalescer = NotificationCoalescer() /// Stuck detection: sessions already notified for the current /// waiting episode. Cleared when the session leaves a waiting state. @@ -54,6 +104,7 @@ final class Notifier { init(sessions: SessionStore, preferences: NotificationPreferences) { self.sessions = sessions self.preferences = preferences + super.init() let timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in Task { @MainActor in self?.sweepStuckSessions() @@ -64,6 +115,8 @@ final class Notifier { if !notificationsAvailable { PerchLog.info("Not running from a .app bundle — notifications are log-only", category: "notify") + } else { + configureNotificationCenter() } } @@ -116,25 +169,40 @@ final class Notifier { func notifyAttention(session: Session, reason: String) { guard preferences.attention else { return } + guard !coalescer.shouldSuppressAttention(for: session.key) else { + PerchLog.info("Coalesced attention behind danger alert for \(session.key.id)", + category: "notify") + return + } let fingerprint = "attention|\(session.key.agent.rawValue)|\(session.key.id)|\(reason)" guard shouldFire(fingerprint) else { return } post(title: "\(session.displayTitle) needs attention", body: reason, - threadId: session.key.id) + threadId: session.key.id, + route: .sessions, + sessionKey: session.key, + category: NotificationID.sessionCategory) } /// Danger-level detection: fires regardless of whether the agent will /// prompt (a whitelisted or auto-approved dangerous call is exactly the /// one you must hear about). - func notifyRisk(session: Session, toolName: String, risk: RiskAssessment) { + func notifyRisk(session: Session, entry: RiskFeed.Entry) { guard preferences.dangerousCalls else { return } - let codes = risk.findings.map(\.code).joined(separator: ",") - let fingerprint = "risk|\(session.key.agent.rawValue)|\(session.key.id)|\(toolName)|\(codes)" + // RiskFeed has already collapsed duplicate hook callbacks into one + // retained entry. Key this final guard by that entry so two distinct + // dangerous calls with the same tool/findings are never conflated. + let fingerprint = "risk|\(entry.id.uuidString)" guard shouldFire(fingerprint) else { return } - let detail = risk.findings.map(\.message).joined(separator: "; ") - post(title: "\(session.displayTitle): dangerous \(toolName) call", + coalescer.recordRisk(for: session.key) + let detail = entry.risk.findings.map(\.message).joined(separator: "; ") + post(title: "\(session.displayTitle): dangerous \(entry.toolName) call", body: detail.isEmpty ? "Flagged by Perch's risk detector." : detail, - threadId: session.key.id) + threadId: session.key.id, + route: .detections, + sessionKey: session.key, + detectionID: entry.id, + category: NotificationID.riskCategory) } func notifyTaskComplete(session: Session, message: String?) { @@ -149,7 +217,11 @@ final class Notifier { } post(title: "\(session.displayTitle) finished", body: body, - threadId: session.key.id) + threadId: session.key.id, + route: .sessions, + sessionKey: session.key, + detectionID: nil, + category: NotificationID.sessionCategory) } func notifyUsageThreshold(label: String, pct: Double) { @@ -158,7 +230,11 @@ final class Notifier { guard shouldFire(fingerprint) else { return } post(title: "\(label) usage at \(Int(pct.rounded()))%", body: "The \(label) rate-limit window is \(Int(pct.rounded()))% used.", - threadId: "usage") + threadId: "usage", + route: .usage, + sessionKey: nil, + detectionID: nil, + category: NotificationID.usageCategory) } // MARK: - Stuck-session sweep (60s timer) @@ -193,7 +269,9 @@ final class Notifier { return true } - private func post(title: String, body: String, threadId: String) { + private func post(title: String, body: String, threadId: String, + route: Route, sessionKey: SessionKey?, detectionID: UUID? = nil, + category: String) { guard notificationsAvailable else { PerchLog.info("NOTIFY (log-only): \(title) — \(body)", category: "notify") return @@ -203,6 +281,16 @@ final class Notifier { content.body = body content.sound = preferences.sounds ? .default : nil content.threadIdentifier = threadId + content.categoryIdentifier = category + var userInfo: [String: String] = [NotificationID.routeKey: route.rawValue] + if let sessionKey { + userInfo[NotificationID.agentKey] = sessionKey.agent.rawValue + userInfo[NotificationID.sessionKey] = sessionKey.id + } + if let detectionID { + userInfo[NotificationID.detectionKey] = detectionID.uuidString + } + content.userInfo = userInfo let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) @@ -213,4 +301,65 @@ final class Notifier { } } } + + private func configureNotificationCenter() { + let center = UNUserNotificationCenter.current() + center.delegate = self + let risk = UNNotificationCategory( + identifier: NotificationID.riskCategory, + actions: [UNNotificationAction(identifier: NotificationID.openDetections, + title: "Open Detection", options: [.foreground])], + intentIdentifiers: []) + let session = UNNotificationCategory( + identifier: NotificationID.sessionCategory, + actions: [UNNotificationAction(identifier: NotificationID.openSessions, + title: "Open Sessions", options: [.foreground])], + intentIdentifiers: []) + let usage = UNNotificationCategory( + identifier: NotificationID.usageCategory, + actions: [UNNotificationAction(identifier: NotificationID.openUsage, + title: "Open Usage", options: [.foreground])], + intentIdentifiers: []) + center.setNotificationCategories([risk, session, usage]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let options: UNNotificationPresentationOptions = notification.request.content.sound == nil + ? [.banner, .list] + : [.banner, .list, .sound] + completionHandler(options) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + guard response.actionIdentifier != UNNotificationDismissActionIdentifier else { + completionHandler() + return + } + let info = response.notification.request.content.userInfo + let route = info[NotificationID.routeKey] as? String + let agent = (info[NotificationID.agentKey] as? String).flatMap(AgentKind.init(rawValue:)) + let sessionID = info[NotificationID.sessionKey] as? String + let detectionID = (info[NotificationID.detectionKey] as? String).flatMap(UUID.init(uuidString:)) + completionHandler() + + Task { @MainActor [weak self] in + let key = agent.flatMap { agent in + sessionID.map { SessionKey(agent: agent, id: $0) } + } + switch route.flatMap(Route.init(rawValue:)) { + case .detections: self?.onOpenDetections?(detectionID) + case .sessions: self?.onOpenSessions?(key) + case .usage: self?.onOpenUsage?() + case nil: break + } + } + } } diff --git a/Sources/Perch/Model/RiskFeed.swift b/Sources/Perch/Model/RiskFeed.swift index be34b76..6172d85 100644 --- a/Sources/Perch/Model/RiskFeed.swift +++ b/Sources/Perch/Model/RiskFeed.swift @@ -53,13 +53,23 @@ final class RiskFeed: ObservableObject { @discardableResult func add(key: SessionKey, toolName: String, toolInput: JSONValue, cwd: String?, risk: RiskAssessment, receivedAt: Date = Date()) -> Bool { - guard !risk.isEmpty else { return false } + addEntry(key: key, toolName: toolName, toolInput: toolInput, + cwd: cwd, risk: risk, receivedAt: receivedAt) != nil + } + + /// Entry-returning form used by notification routing so a banner can open + /// the exact retained detection rather than merely the newest session row. + @discardableResult + func addEntry(key: SessionKey, toolName: String, toolInput: JSONValue, + cwd: String?, risk: RiskAssessment, + receivedAt: Date = Date()) -> Entry? { + guard !risk.isEmpty else { return nil } pruneRecent(now: receivedAt) if recent.contains(where: { $0.key == key && $0.toolName == toolName && $0.toolInput == toolInput && receivedAt.timeIntervalSince($0.receivedAt) < Self.dedupeWindow }) { - return false + return nil } let entry = Entry(id: UUID(), key: key, toolName: toolName, toolInput: toolInput, cwd: cwd, receivedAt: receivedAt, risk: risk) @@ -71,7 +81,7 @@ final class RiskFeed: ObservableObject { scheduleExpiry(for: entry.id) scheduleRecentExpiry(for: entry.id) onAdd?(entry) - return true + return entry } func dismissFocused() { diff --git a/Sources/Perch/Model/SessionStore.swift b/Sources/Perch/Model/SessionStore.swift index cf5cde7..15919cb 100644 --- a/Sources/Perch/Model/SessionStore.swift +++ b/Sources/Perch/Model/SessionStore.swift @@ -18,8 +18,9 @@ final class SessionStore: ObservableObject { var onAttention: ((Session, String) -> Void)? var onTaskComplete: ((Session, String?) -> Void)? /// Fired when the detector flags a danger-level tool call — OS - /// notification + notch attention. (session, toolName, risk) - var onRiskDetected: ((Session, String, RiskAssessment) -> Void)? + /// notification + notch attention. The retained entry gives notification + /// clicks an exact read-only destination. + var onRiskDetected: ((Session, RiskFeed.Entry) -> Void)? private var map: [SessionKey: Session] = [:] @@ -265,15 +266,22 @@ final class SessionStore: ObservableObject { // PreToolUse and PermissionRequest both fire for the same call; the // feed's dedupe decides what counts as a new event, so the score and // the notification tally each real call exactly once. - let isNewEvent = riskFeed?.add(key: key, - toolName: toolName, - toolInput: toolInput ?? .null, - cwd: cwd ?? find(agent: agent, id: sid)?.cwd, - risk: risk) ?? true - guard isNewEvent else { return } + let resolvedInput = toolInput ?? .null + let resolvedCwd = cwd ?? find(agent: agent, id: sid)?.cwd + let entry: RiskFeed.Entry + if let riskFeed { + guard let added = riskFeed.addEntry(key: key, toolName: toolName, + toolInput: resolvedInput, cwd: resolvedCwd, + risk: risk) else { return } + entry = added + } else { + entry = RiskFeed.Entry(id: UUID(), key: key, toolName: toolName, + toolInput: resolvedInput, cwd: resolvedCwd, + receivedAt: Date(), risk: risk) + } securityPosture?.record(risk.level) if risk.level == .danger, let session = find(agent: agent, id: sid) { - onRiskDetected?(session, toolName, risk) + onRiskDetected?(session, entry) } } diff --git a/Sources/Perch/UI/MonitoringHealthBadgeView.swift b/Sources/Perch/UI/MonitoringHealthBadgeView.swift index d5f37c4..d66430e 100644 --- a/Sources/Perch/UI/MonitoringHealthBadgeView.swift +++ b/Sources/Perch/UI/MonitoringHealthBadgeView.swift @@ -7,16 +7,17 @@ struct MonitoringHealthBadgeView: View { let onOpen: () -> Void var body: some View { + let presentation = health.presentation Button(action: onOpen) { HStack(spacing: 7) { - Image(systemName: icon) + Image(systemName: icon(for: presentation.state)) .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(color) - Text(health.snapshot.title) + .foregroundStyle(color(for: presentation.state)) + Text(presentation.title) .font(.caption.weight(.semibold)) .foregroundStyle(.primary) Spacer(minLength: 6) - Text(health.snapshot.summary) + Text(presentation.summary) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -26,15 +27,16 @@ struct MonitoringHealthBadgeView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Capsule().fill(color.opacity(0.10))) - .overlay(Capsule().strokeBorder(color.opacity(0.24), lineWidth: 1)) + .background(Capsule().fill(color(for: presentation.state).opacity(0.10))) + .overlay(Capsule().strokeBorder( + color(for: presentation.state).opacity(0.24), lineWidth: 1)) } .buttonStyle(.plain) .help("Open Monitoring Setup and health details") } - private var color: Color { - switch health.snapshot.state { + private func color(for state: MonitoringCheckState) -> Color { + switch state { case .checking: return .secondary case .ready: return PerchTheme.running case .needsAttention: return PerchTheme.attention @@ -42,8 +44,8 @@ struct MonitoringHealthBadgeView: View { } } - private var icon: String { - switch health.snapshot.state { + private func icon(for state: MonitoringCheckState) -> String { + switch state { case .checking: return "ellipsis.circle" case .ready: return "wave.3.right.circle.fill" case .needsAttention: return "wrench.and.screwdriver.fill" diff --git a/Sources/Perch/UI/NotchController.swift b/Sources/Perch/UI/NotchController.swift index ad91749..534249c 100644 --- a/Sources/Perch/UI/NotchController.swift +++ b/Sources/Perch/UI/NotchController.swift @@ -12,6 +12,8 @@ enum NotchPage { case sessions, integrity } final class NotchViewState: ObservableObject { @Published var isExpanded = false @Published var page: NotchPage = .sessions + @Published var focusedSessionKey: SessionKey? + @Published var sessionFocusRequest = 0 @Published var hasAttention = false @Published var hasNotch = false @Published var pillSize = NotchGeometry.fallbackPillSize @@ -97,8 +99,13 @@ final class NotchController { if state.isExpanded { collapse() } else { expand() } } - func expand() { + func expand(focusing sessionKey: SessionKey? = nil) { guard panel != nil else { return } + if let sessionKey { + state.page = .sessions + state.focusedSessionKey = sessionKey + state.sessionFocusRequest += 1 + } cancelScheduledCollapse() cancelScheduledHoverExpand() if !state.isExpanded { diff --git a/Sources/Perch/UI/NotchRootView.swift b/Sources/Perch/UI/NotchRootView.swift index 73a07d6..048e1c5 100644 --- a/Sources/Perch/UI/NotchRootView.swift +++ b/Sources/Perch/UI/NotchRootView.swift @@ -94,7 +94,7 @@ struct NotchRootView: View { if state.hasNotch { Spacer(minLength: 0) // push the visible band below the cutout } - PillView(sessions: sessions, hasAttention: state.hasAttention) + PillView(sessions: sessions, health: health, hasAttention: state.hasAttention) .frame(height: state.hasNotch ? NotchGeometry.pillBandHeight : state.pillSize.height) } .frame(width: state.pillSize.width, height: state.pillSize.height) @@ -218,18 +218,37 @@ struct NotchRootView: View { } else if renderStatic { VStack(alignment: .leading, spacing: 6) { ForEach(sessions.sessions) { session in - SessionRowView(session: session) + sessionRow(session) } Spacer(minLength: 0) } } else { - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: 6) { - ForEach(sessions.sessions) { session in - SessionRowView(session: session) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(sessions.sessions) { session in + sessionRow(session) + .id(session.key) + } } } + .onAppear { scrollToFocusedSession(using: proxy) } + .onChange(of: state.sessionFocusRequest) { _, _ in + scrollToFocusedSession(using: proxy) + } } } } + + private func sessionRow(_ session: Session) -> some View { + SessionRowView(session: session, + isNotificationTarget: state.focusedSessionKey == session.key) + } + + private func scrollToFocusedSession(using proxy: ScrollViewProxy) { + guard let key = state.focusedSessionKey else { return } + DispatchQueue.main.async { + withAnimation { proxy.scrollTo(key, anchor: .center) } + } + } } diff --git a/Sources/Perch/UI/PillView.swift b/Sources/Perch/UI/PillView.swift index b729efc..d1da263 100644 --- a/Sources/Perch/UI/PillView.swift +++ b/Sources/Perch/UI/PillView.swift @@ -4,6 +4,7 @@ import SwiftUI /// (green executing / gray idle / amber attention pulse). struct PillView: View { @ObservedObject var sessions: SessionStore + @ObservedObject var health: MonitoringHealth let hasAttention: Bool private var liveSessions: [Session] { @@ -12,6 +13,12 @@ struct PillView: View { var body: some View { HStack(spacing: 6) { + Image(systemName: healthIcon) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(healthColor) + .accessibilityLabel(health.presentation.title) + .help("\(health.presentation.title): \(health.presentation.summary)") + Text("\(liveSessions.count)") .font(.system(size: 11, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -32,6 +39,24 @@ struct PillView: View { .padding(.horizontal, 10) .frame(maxWidth: .infinity) } + + private var healthColor: Color { + switch health.presentation.state { + case .checking: return .secondary + case .ready: return PerchTheme.running + case .needsAttention: return PerchTheme.attention + case .unavailable: return PerchTheme.danger + } + } + + private var healthIcon: String { + switch health.presentation.state { + case .checking: return "ellipsis.circle" + case .ready: return "checkmark.circle.fill" + case .needsAttention: return "exclamationmark.triangle.fill" + case .unavailable: return "xmark.circle.fill" + } + } } /// One 5pt dot per session, colored by state. diff --git a/Sources/Perch/UI/RecentDetectionsWindow.swift b/Sources/Perch/UI/RecentDetectionsWindow.swift index a3a1cf6..063ae53 100644 --- a/Sources/Perch/UI/RecentDetectionsWindow.swift +++ b/Sources/Perch/UI/RecentDetectionsWindow.swift @@ -2,10 +2,23 @@ import AppKit import SwiftUI import PerchCore +@MainActor +private final class RecentDetectionSelection: ObservableObject { + @Published var focusID: UUID? + @Published private(set) var focusRequest = 0 + + func requestFocus(_ id: UUID?) { + focusID = id + focusRequest += 1 + } +} + @MainActor final class RecentDetectionsWindowController: NSWindowController { + private let selection = RecentDetectionSelection() + init(feed: RiskFeed, posture: SecurityPosture) { - let root = RecentDetectionsView(feed: feed, posture: posture) + let root = RecentDetectionsView(feed: feed, posture: posture, selection: selection) let hosting = NSHostingController(rootView: root) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 680, height: 560), @@ -20,7 +33,8 @@ final class RecentDetectionsWindowController: NSWindowController { required init?(coder: NSCoder) { nil } - func show() { + func show(focusing id: UUID? = nil) { + selection.requestFocus(id) showWindow(nil) NSApp.activate(ignoringOtherApps: true) window?.makeKeyAndOrderFront(nil) @@ -30,6 +44,7 @@ final class RecentDetectionsWindowController: NSWindowController { private struct RecentDetectionsView: View { @ObservedObject var feed: RiskFeed @ObservedObject var posture: SecurityPosture + @ObservedObject var selection: RecentDetectionSelection var body: some View { VStack(spacing: 0) { @@ -38,13 +53,20 @@ private struct RecentDetectionsView: View { if feed.recent.isEmpty { emptyState } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(Array(feed.recent.reversed())) { entry in - detectionRow(entry) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(Array(feed.recent.reversed())) { entry in + detectionRow(entry) + .id(entry.id) + } } + .padding(18) + } + .onAppear { scrollToFocused(using: proxy) } + .onChange(of: selection.focusRequest) { _, _ in + scrollToFocused(using: proxy) } - .padding(18) } } } @@ -135,7 +157,9 @@ private struct RecentDetectionsView: View { } .padding(13) .background(RoundedRectangle(cornerRadius: 12).fill(Color.primary.opacity(0.045))) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(color.opacity(0.20))) + .overlay(RoundedRectangle(cornerRadius: 12).stroke( + targetEntryID == entry.id ? color.opacity(0.85) : color.opacity(0.20), + lineWidth: targetEntryID == entry.id ? 2 : 1)) } private func metadata(for entry: RiskFeed.Entry) -> String { @@ -151,4 +175,15 @@ private struct RecentDetectionsView: View { case .high: return .red } } + + private var targetEntryID: UUID? { + selection.focusID + } + + private func scrollToFocused(using proxy: ScrollViewProxy) { + guard let id = targetEntryID else { return } + DispatchQueue.main.async { + withAnimation { proxy.scrollTo(id, anchor: .center) } + } + } } diff --git a/Sources/Perch/UI/SessionRowView.swift b/Sources/Perch/UI/SessionRowView.swift index 62e865a..a7823ca 100644 --- a/Sources/Perch/UI/SessionRowView.swift +++ b/Sources/Perch/UI/SessionRowView.swift @@ -5,6 +5,7 @@ import PerchCore /// the most recent tool events, described in plain language. struct SessionRowView: View { let session: Session + var isNotificationTarget = false @State private var expanded = false @@ -26,9 +27,7 @@ struct SessionRowView: View { ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(session.needsAttention - ? PerchTheme.attention.opacity(0.45) - : PerchTheme.cardBorder, lineWidth: 1) + .strokeBorder(borderColor, lineWidth: isNotificationTarget ? 2 : 1) ) .contentShape(Rectangle()) .onTapGesture { @@ -161,6 +160,13 @@ struct SessionRowView: View { // MARK: - State presentation + private var borderColor: Color { + if isNotificationTarget { return PerchTheme.running.opacity(0.85) } + return session.needsAttention + ? PerchTheme.attention.opacity(0.45) + : PerchTheme.cardBorder + } + private var stateColor: Color { PerchTheme.stateColor(session.state) } diff --git a/Sources/Perch/UI/SetupWindowController.swift b/Sources/Perch/UI/SetupWindowController.swift index 86a2e25..049f07b 100644 --- a/Sources/Perch/UI/SetupWindowController.swift +++ b/Sources/Perch/UI/SetupWindowController.swift @@ -4,6 +4,18 @@ import PerchCore @MainActor final class SetupViewModel: ObservableObject { + enum ResultTarget: Hashable { + case claude + case codex + case doctor + } + + enum Outcome: Equatable { + case success + case attention + case failure + } + enum Operation: Equatable { case installClaude case installCodex @@ -20,16 +32,25 @@ final class SetupViewModel: ObservableObject { case .doctor: return "Running Doctor…" } } + + var target: ResultTarget { + switch self { + case .installClaude, .uninstallClaude: return .claude + case .installCodex, .uninstallCodex: return .codex + case .doctor: return .doctor + } + } } struct Result { let title: String let text: String - let failed: Bool + let outcome: Outcome + let checks: [MonitoringCheck] } @Published private(set) var operation: Operation? - @Published private(set) var result: Result? + @Published private(set) var results: [ResultTarget: Result] = [:] let health: MonitoringHealth let preferences: NotificationPreferences @@ -51,39 +72,56 @@ final class SetupViewModel: ObservableObject { } func installClaude() { - perform(.installClaude, resultTitle: "Claude Code monitoring") { - Self.runReporting { + health.clearVerification(for: .claude) + perform(.installClaude) { + Self.runInstall(title: "Claude Code monitoring") { _ = try BridgeDeployer.deploy() return try ClaudeHookInstaller.install() + } verify: { + ClaudeHookInstaller.installationStatus().isReady } } } func installCodex() { - perform(.installCodex, resultTitle: "Codex monitoring") { - Self.runReporting { + health.clearVerification(for: .codex) + perform(.installCodex) { + Self.runInstall(title: "Codex monitoring") { _ = try BridgeDeployer.deploy() var report = try CodexHookInstaller.install() report.notes += CodexHookTrust.ensureTrusted() return report + } verify: { + CodexHookInstaller.installationStatus().isReady + && (CodexHookTrust.storedTrustRecordCount() ?? 0) > 0 } } } func uninstallClaude() { - perform(.uninstallClaude, resultTitle: "Claude Code monitoring") { - Self.runReporting { try ClaudeHookInstaller.uninstall() } + health.clearVerification(for: .claude) + perform(.uninstallClaude) { + Self.runInstall(title: "Claude Code monitoring") { + try ClaudeHookInstaller.uninstall() + } } } func uninstallCodex() { - perform(.uninstallCodex, resultTitle: "Codex monitoring") { - Self.runReporting { try CodexHookInstaller.uninstall() } + health.clearVerification(for: .codex) + perform(.uninstallCodex) { + Self.runInstall(title: "Codex monitoring") { + try CodexHookInstaller.uninstall() + } } } func runDoctor() { - perform(.doctor, resultTitle: "Perch Doctor") { Doctor.report() } + perform(.doctor) { + let report = Doctor.diagnose() + return Result(title: "Perch Doctor", text: report.text, + outcome: Self.outcome(for: report.state), checks: report.checks) + } } func requestNotifications() { @@ -104,28 +142,49 @@ final class SetupViewModel: ObservableObject { onDone?() } - private func perform(_ operation: Operation, resultTitle: String, - work: @escaping () -> String) { + func result(for target: ResultTarget) -> Result? { + results[target] + } + + private func perform(_ operation: Operation, work: @escaping () -> Result) { guard self.operation == nil else { return } self.operation = operation - result = nil + results[operation.target] = nil DispatchQueue.global(qos: .userInitiated).async { - let text = work() + let result = work() DispatchQueue.main.async { [weak self] in guard let self else { return } self.operation = nil - self.result = Result(title: resultTitle, text: text, - failed: text.hasPrefix("Failed:")) + self.results[operation.target] = result self.refresh() } } } - private static func runReporting(_ body: () throws -> InstallReport) -> String { + private static func runInstall(title: String, + _ body: () throws -> InstallReport, + verify: (() -> Bool)? = nil) -> Result { do { - return try body().summaryText + let report = try body() + let verified = verify?() + let outcome: Outcome = verified == false ? .attention : .success + let text = verified == false + ? report.summaryText + + "\nStatic verification still needs attention. Review the status above or run Doctor." + : report.summaryText + return Result(title: title, text: text, + outcome: outcome, checks: []) } catch { - return "Failed: \(error.localizedDescription)" + return Result(title: title, text: error.localizedDescription, + outcome: .failure, checks: []) + } + } + + static func outcome(for state: MonitoringCheckState) -> Outcome { + switch state { + case .ready: return .success + case .checking, .needsAttention: return .attention + case .unavailable: return .failure } } } @@ -178,6 +237,7 @@ private struct SetupView: View { header runtimeSection integrationsSection + verificationSection notificationsSection doctorSection } @@ -197,25 +257,26 @@ private struct SetupView: View { } private var header: some View { - HStack(alignment: .top, spacing: 14) { - Image(systemName: healthIcon) + let presentation = health.presentation + return HStack(alignment: .top, spacing: 14) { + Image(systemName: icon(for: presentation.state)) .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(healthColor) + .foregroundStyle(color(for: presentation.state)) .frame(width: 36) VStack(alignment: .leading, spacing: 5) { - Text(health.snapshot.title) + Text(presentation.title) .font(.title2.weight(.semibold)) - Text(health.snapshot.summary) + Text(presentation.summary) .foregroundStyle(.secondary) Text("Coverage and detections are read-only. Perch observes; it never approves, denies, or blocks an agent action.") .font(.caption) .foregroundStyle(.secondary) if let lastEventAt = health.lastEventAt { - Text("Last hook event \(lastEventAt, style: .relative)") + Text("Last verified hook event \(lastEventAt, style: .relative)") .font(.caption2) .foregroundStyle(.secondary) } else if health.snapshot.state == .ready { - Text("Connected · waiting for the first hook event") + Text("Static checks passed · waiting for a real hook event") .font(.caption2) .foregroundStyle(.secondary) } @@ -224,8 +285,10 @@ private struct SetupView: View { if health.isRefreshing { ProgressView().controlSize(.small) } } .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(healthColor.opacity(0.10))) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(healthColor.opacity(0.25))) + .background(RoundedRectangle(cornerRadius: 14) + .fill(color(for: presentation.state).opacity(0.10))) + .overlay(RoundedRectangle(cornerRadius: 14) + .stroke(color(for: presentation.state).opacity(0.25))) } private var runtimeSection: some View { @@ -239,13 +302,36 @@ private struct SetupView: View { private var integrationsSection: some View { section("Agent integrations", subtitle: "Install at least one integration for tool-risk coverage.") { integrationRow(health.snapshot.claude, + target: .claude, install: model.installClaude, uninstall: model.uninstallClaude) Divider() integrationRow(health.snapshot.codex, + target: .codex, install: model.installCodex, uninstall: model.uninstallCodex) } } + private var verificationSection: some View { + section("Live verification", + subtitle: "A real hook event confirms the configured path works end to end.") { + if health.snapshot.configuredAgents.isEmpty { + Text("Install an integration above, then start or restart its agent.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + if health.snapshot.claude.isReady { + verificationRow(.claude) + } + if health.snapshot.claude.isReady && health.snapshot.codex.isReady { + Divider() + } + if health.snapshot.codex.isReady { + verificationRow(.codex) + } + } + } + } + private var notificationsSection: some View { section("Notifications", subtitle: "Choose which local events may interrupt you.") { HStack(spacing: 10) { @@ -287,68 +373,130 @@ private struct SetupView: View { Button("Run Doctor") { model.runDoctor() } .disabled(model.operation != nil) } - if let result = model.result { + if let result = model.result(for: .doctor) { Divider() - VStack(alignment: .leading, spacing: 7) { - Label(result.title, - systemImage: result.failed ? "xmark.circle.fill" : "checkmark.circle.fill") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(result.failed ? Color.red : Color.primary) - ScrollView(.vertical) { - Text(result.text) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 9) { + resultHeader(result) + ForEach(result.checks, id: \.title) { check in + checkRow(check, showDetail: true) } - .frame(maxHeight: 170) + DisclosureGroup("Technical details") { + ScrollView(.vertical) { + Text(result.text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 170) + } + .font(.caption) } - } else if let operation = model.operation { + } + } + } + + private func integrationRow(_ check: MonitoringCheck, + target: SetupViewModel.ResultTarget, + install: @escaping () -> Void, + uninstall: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + statusIcon(check.state) + VStack(alignment: .leading, spacing: 2) { + Text(check.title).font(.subheadline.weight(.medium)) + Text(check.summary).font(.caption).foregroundStyle(.secondary) + if let detail = check.detail { + Text(detail).font(.caption2).foregroundStyle(.tertiary) + } + } + Spacer() + Button(check.isReady ? "Repair" : "Install") { install() } + .disabled(model.operation != nil) + Menu { + Button("Uninstall Perch integration", action: uninstall) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(model.operation != nil) + } + if model.operation?.target == target, let operation = model.operation { Divider() HStack(spacing: 8) { ProgressView().controlSize(.small) Text(operation.title).font(.caption).foregroundStyle(.secondary) } + } else if let result = model.result(for: target) { + Divider() + VStack(alignment: .leading, spacing: 6) { + resultHeader(result) + Text(result.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } } } } - private func integrationRow(_ check: MonitoringCheck, - install: @escaping () -> Void, - uninstall: @escaping () -> Void) -> some View { - HStack(alignment: .center, spacing: 10) { + private func checkRow(_ check: MonitoringCheck, showDetail: Bool = false) -> some View { + HStack(spacing: 10) { statusIcon(check.state) VStack(alignment: .leading, spacing: 2) { Text(check.title).font(.subheadline.weight(.medium)) Text(check.summary).font(.caption).foregroundStyle(.secondary) - if let detail = check.detail { + if showDetail, let detail = check.detail { Text(detail).font(.caption2).foregroundStyle(.tertiary) + .textSelection(.enabled) } } Spacer() - Button(check.isReady ? "Repair" : "Install") { install() } - .disabled(model.operation != nil) - Menu { - Button("Uninstall Perch integration", action: uninstall) - } label: { - Image(systemName: "ellipsis.circle") - } - .menuStyle(.borderlessButton) - .fixedSize() - .disabled(model.operation != nil) } } - private func checkRow(_ check: MonitoringCheck) -> some View { - HStack(spacing: 10) { - statusIcon(check.state) + private func verificationRow(_ agent: AgentKind) -> some View { + let lastEventAt = health.lastEventAt(for: agent) + let title = agent == .claude ? "Claude Code" : "Codex" + return HStack(spacing: 10) { + statusIcon(health.verificationState(for: agent)) VStack(alignment: .leading, spacing: 2) { - Text(check.title).font(.subheadline.weight(.medium)) - Text(check.summary).font(.caption).foregroundStyle(.secondary) + Text(title).font(.subheadline.weight(.medium)) + if let lastEventAt { + Text("Hook event received \(lastEventAt, style: .relative)") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Configured · start or restart \(title) to verify") + .font(.caption).foregroundStyle(.orange) + } } Spacer() } } + private func resultHeader(_ result: SetupViewModel.Result) -> some View { + Label(result.title, systemImage: resultIcon(result.outcome)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(resultColor(result.outcome)) + } + + private func resultIcon(_ outcome: SetupViewModel.Outcome) -> String { + switch outcome { + case .success: return "checkmark.circle.fill" + case .attention: return "exclamationmark.triangle.fill" + case .failure: return "xmark.circle.fill" + } + } + + private func resultColor(_ outcome: SetupViewModel.Outcome) -> Color { + switch outcome { + case .success: return .green + case .attention: return .orange + case .failure: return .red + } + } + private func section(_ title: String, subtitle: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { @@ -376,9 +524,6 @@ private struct SetupView: View { } } - private var healthIcon: String { icon(for: health.snapshot.state) } - private var healthColor: Color { color(for: health.snapshot.state) } - private func icon(for state: MonitoringCheckState) -> String { switch state { case .checking: return "ellipsis.circle" diff --git a/Sources/Perch/UI/StatusItemController.swift b/Sources/Perch/UI/StatusItemController.swift index 663f839..c2c8428 100644 --- a/Sources/Perch/UI/StatusItemController.swift +++ b/Sources/Perch/UI/StatusItemController.swift @@ -55,6 +55,11 @@ final class StatusItemController: NSObject, NSMenuDelegate { riskFeed.$entries .sink { [weak self] _ in self?.updateBadge() } .store(in: &cancellables) + health.objectWillChange + .sink { [weak self] _ in + DispatchQueue.main.async { self?.updateBadge() } + } + .store(in: &cancellables) updateBadge() } @@ -80,6 +85,9 @@ final class StatusItemController: NSObject, NSMenuDelegate { } else { button.attributedTitle = NSAttributedString(string: "") } + let presentation = health.presentation + button.contentTintColor = Self.healthColor(presentation.state) + button.toolTip = "Perch — \(presentation.title): \(presentation.summary)" } // MARK: - NSMenuDelegate (rebuild on open) @@ -92,7 +100,8 @@ final class StatusItemController: NSObject, NSMenuDelegate { health.refresh() // Monitoring coverage is separate from recent risk activity. - menu.addItem(infoItem("\(health.snapshot.title) — \(health.snapshot.summary)")) + let healthPresentation = health.presentation + menu.addItem(infoItem("\(healthPresentation.title) — \(healthPresentation.summary)")) menu.addItem(infoItem("Security \(posture.score)/100 — \(posture.grade.rawValue)", monospacedDigits: true)) menu.addItem(.separator()) @@ -225,6 +234,15 @@ final class StatusItemController: NSObject, NSMenuDelegate { } } + private static func healthColor(_ state: MonitoringCheckState) -> NSColor? { + switch state { + case .checking: return .secondaryLabelColor + case .ready: return .systemGreen + case .needsAttention: return .systemOrange + case .unavailable: return .systemRed + } + } + private static func stateText(_ state: SessionState) -> String { switch state { case .executing: return "running" diff --git a/Sources/PerchCore/PerchConfig.swift b/Sources/PerchCore/PerchConfig.swift index 4a2d326..b93fd08 100644 --- a/Sources/PerchCore/PerchConfig.swift +++ b/Sources/PerchCore/PerchConfig.swift @@ -34,6 +34,11 @@ public struct PerchConfig: Codable, Sendable { public var playNotificationSounds: Bool /// Set once the user finishes or dismisses the guided monitoring setup. public var hasCompletedSetup: Bool + /// Most recent end-to-end hook event observed from each integration. These + /// timestamps distinguish static hook configuration from verified delivery + /// across app launches without retaining any event content. + public var lastClaudeHookEventAt: Date? + public var lastCodexHookEventAt: Date? /// Extra keys we don't model yet — preserved verbatim. public var extra: [String: JSONValue] @@ -50,6 +55,8 @@ public struct PerchConfig: Codable, Sendable { self.notifyUsageThresholds = true self.playNotificationSounds = true self.hasCompletedSetup = false + self.lastClaudeHookEventAt = nil + self.lastCodexHookEventAt = nil self.extra = [:] } @@ -64,6 +71,8 @@ public struct PerchConfig: Codable, Sendable { case notifyUsageThresholds case playNotificationSounds case hasCompletedSetup + case lastClaudeHookEventAt + case lastCodexHookEventAt } public init(from decoder: Decoder) throws { @@ -99,6 +108,12 @@ public struct PerchConfig: Codable, Sendable { if let v = raw["hasCompletedSetup"]?.boolValue { config.hasCompletedSetup = v } + if let seconds = raw["lastClaudeHookEventAt"]?.double { + config.lastClaudeHookEventAt = Date(timeIntervalSince1970: seconds) + } + if let seconds = raw["lastCodexHookEventAt"]?.double { + config.lastCodexHookEventAt = Date(timeIntervalSince1970: seconds) + } if let obj = raw.objectValue { let known = Set(KnownKeys.allCases.map(\.rawValue)) config.extra = obj.filter { !known.contains($0.key) } @@ -128,6 +143,12 @@ public struct PerchConfig: Codable, Sendable { if !notifyUsageThresholds { obj["notifyUsageThresholds"] = .bool(false) } if !playNotificationSounds { obj["playNotificationSounds"] = .bool(false) } if hasCompletedSetup { obj["hasCompletedSetup"] = .bool(true) } + if let lastClaudeHookEventAt { + obj["lastClaudeHookEventAt"] = .number(lastClaudeHookEventAt.timeIntervalSince1970) + } + if let lastCodexHookEventAt { + obj["lastCodexHookEventAt"] = .number(lastCodexHookEventAt.timeIntervalSince1970) + } try JSONValue.object(obj).encode(to: encoder) }