Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions Sources/Perch/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move verification persistence off the sync hook path

For PreToolUse/PermissionRequest, the bridge waits for store.handleEnvelope to invoke reply(.empty), but this new call runs before that and noteEvent can synchronously persistVerification() on the first event and then once per minute. If the config-file write is slow or stuck, these sync hooks are delayed or can hit the bridge timeout even though Perch is supposed to observe without blocking agent actions; record the event after the handler replies or persist asynchronously.

AGENTS.md reference: AGENTS.md:L38-L42

Useful? React with 👍 / 👎.

}
store.handleEnvelope(envelope, reply: reply)
}
}
Expand Down Expand Up @@ -209,6 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationWillTerminate(_ notification: Notification) {
monitoringHealth.persistVerification()
socketServer?.stop()
PerchLog.info("Perch terminated")
}
Expand All @@ -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()
Expand All @@ -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]) {
Expand Down Expand Up @@ -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() {
Expand Down
125 changes: 119 additions & 6 deletions Sources/Perch/App/Selftest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ enum Selftest {
perchConfigCheckForUpdatesRoundTrip(t)
perchConfigWorktreeStaleDaysRoundTrip(t)
perchConfigNotificationPreferencesRoundTrip(t)
perchConfigMonitoringVerificationRoundTrip(t)
semVerParsesAndCompares(t)
updateCheckDecision(t)

Expand All @@ -48,6 +49,9 @@ enum Selftest {
riskFeedDismissAndFocusClamp(t)
riskFeedRetainsRecentDetections(t)
monitoringSnapshotSeparatesCoverageFromPosture(t)
monitoringHealthSeparatesConfigurationFromVerification(t)
notificationCoalescerSuppressesOnlyOverlap(t)
doctorStructuredOutcomeReflectsVisibleChecks(t)
sessionRiskBadgeAgesOut(t)
handleEnvelopeRoutesUserPromptSubmit(t)
handleEnvelopeRoutesStop(t)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions Sources/Perch/App/ShowcaseRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 33 additions & 6 deletions Sources/Perch/Install/Doctor.swift
Original file line number Diff line number Diff line change
@@ -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 "
Expand All @@ -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
Expand Down Expand Up @@ -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)"
}
Expand Down
Loading