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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ and what they've **left behind**:
| 🪶 **Zero footprint** | No third-party dependencies or telemetry, and an auditable all-Swift codebase using macOS's system SQLite for minimal local detection metadata. If Perch dies, your agents don't even notice. |

<details>
<summary><b>🧭 Footholds · 🌳 Worktrees · 📊 token dashboard screenshots</b></summary>
<summary><b>🧭 Footholds · 🔎 Insights · 🌳 Worktrees · 📊 token dashboard screenshots</b></summary>
<div align="center">
<img src="docs/img/integrity.png" width="560" alt="Footholds page: agent-config, instructions/memory, and system-persistence surfaces with per-item state — a non-Perch hook in settings.json, recently-changed project CLAUDE.md files, LaunchAgents and shell profiles">
<br><br>
<img src="docs/img/insights.png" width="640" alt="Insights window: caution and danger totals, a 24-hour stacked detection timeline, findings by stable code, detections by agent and tool, and per-session finding clusters — read locally from the metadata-only detection store">
<br><br>
<img src="docs/img/worktrees.png" width="640" alt="Worktrees window: summary tiles for count, total size, and reclaimable bytes; per-project rows with reclaimable / review / active / orphaned tier badges, dirty-file and commits-ahead notes, and a Copy cleanup commands button — read-only, Perch never deletes">
<br><br>
<img src="docs/img/usage.png" width="680" alt="Token usage dashboard: daily stacked chart plus per-day, per-model and per-project breakdowns">
Expand Down
64 changes: 60 additions & 4 deletions Sources/Perch/App/ShowcaseRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum ShowcaseRenderer {
try renderIntegrity(to: dir.appendingPathComponent("integrity.png"))
try renderUsage(to: dir.appendingPathComponent("usage.png"))
try renderWorktrees(to: dir.appendingPathComponent("worktrees.png"))
try renderInsights(to: dir.appendingPathComponent("insights.png"))
}

/// Synthetic worktree audit covering all four tiers.
Expand Down Expand Up @@ -70,6 +71,10 @@ enum ShowcaseRenderer {
detail: "present", lastModified: nil, status: .unchanged),
IntegrityItem(id: "pl", category: .agentConfig, label: "~/.claude/plugins",
detail: "3 plugins", lastModified: nil, status: .unchanged),
IntegrityItem(id: "mcp", category: .agentConfig, label: "MCP servers",
detail: "2 configured", lastModified: nil, status: .unchanged),
IntegrityItem(id: "cx", category: .agentConfig, label: "~/.codex/config.toml + hooks.json",
detail: "hooks present · trusted", lastModified: nil, status: .unchanged),
IntegrityItem(id: "cm", category: .memory, label: "Project CLAUDE.md / AGENTS.md",
detail: "5 across active projects", lastModified: nil, status: .changedRecently),
IntegrityItem(id: "mem", category: .memory, label: "~/.claude/memory",
Expand All @@ -84,11 +89,11 @@ enum ShowcaseRenderer {
private static func renderIntegrity(to url: URL) throws {
let model = IntegrityModel()
model.injectSnapshot(demoIntegritySnapshot())
// All showcase shots share a 760pt output width so galleries can
// display them at one uniform size without per-image caps.
let size = CGSize(width: 720, height: 430)
// Canvas matches the worktrees shot exactly (760x520 with margins) so
// the two sit in one gallery row at equal height and column width.
let size = CGSize(width: 720, height: 480)
let view = IntegrityView(model: model, renderStatic: true)
.frame(width: 680, height: 390, alignment: .top)
.frame(width: 680, height: 440, alignment: .top)
.frame(width: size.width, height: size.height, alignment: .top)
.padding(20)
.background(
Expand Down Expand Up @@ -233,6 +238,57 @@ enum ShowcaseRenderer {
try writePNG(view, size: size, to: url)
}

// MARK: - Insights window

/// Synthetic past-24h detection metadata: a burst on the api-server
/// session, an older Codex credential read, and background cautions —
/// aggregated through the real Insights pipeline so buckets, ordering,
/// and labels match what the window computes from SQLite.
static func demoInsightsSnapshot(now: Date = Date()) -> DetectionInsightsSnapshot {
func row(_ eventID: String, _ hoursAgo: Double, _ agent: AgentKind,
_ session: String, _ tool: String, _ risk: RiskLevel,
_ code: String, _ level: RiskLevel) -> DetectionInsightsSourceRow {
DetectionInsightsSourceRow(
eventID: eventID,
observedAt: now.addingTimeInterval(-hoursAgo * 3600),
agent: agent, sessionID: session, toolName: tool,
riskLevel: risk, findingCode: code, findingLevel: level)
}
let api = "0f47a2c9-8f4e-4f6a-b0d3-2d9c51e7aa41"
let data = "5a19e7d4-63bb-4c21-9e5a-0c7f4b8812df"
let rows = [
row("e1", 1.4, .claude, api, "Bash", .danger, "pipe-to-shell", .danger),
row("e1", 1.4, .claude, api, "Bash", .danger, "privilege-escalation", .danger),
row("e2", 1.6, .claude, api, "Bash", .caution, "force-push", .caution),
row("e3", 4.5, .claude, api, "Edit", .caution, "memory-pollution", .caution),
row("e4", 8.2, .claude, api, "Bash", .caution, "destructive-delete", .caution),
row("e5", 12.3, .codex, data, "shell", .danger, "credential-access", .danger),
row("e6", 12.8, .codex, data, "shell", .caution, "insecure-url", .caution),
row("e7", 21.5, .codex, data, "shell", .caution, "destructive-delete", .caution),
]
return DetectionInsightsSnapshot.aggregate(
rows: rows, range: .hours24, now: now, calendar: Calendar.current)
}

private static func renderInsights(to url: URL) throws {
// The store is never opened: injectSnapshot short-circuits refresh(),
// so this render touches no SQLite file.
let model = InsightsModel(store: DetectionStore())
model.injectSnapshot(demoInsightsSnapshot())

// Width is chosen so the aspect ratio equals the usage shot's
// (760x780): the two share a gallery row at equal height and column
// width. The window is resizable in the app, so the wide layout is
// exactly what a user would see.
let size = CGSize(width: 1091, height: 1120)
let view = InsightsView(model: model, renderStatic: true)
.frame(width: size.width, height: size.height, alignment: .top)
.background(Color(nsColor: .windowBackgroundColor))
.environment(\.colorScheme, .dark)

try writePNG(view, size: size, to: url)
}

// MARK: - Usage dashboard

/// Two weeks of plausible-looking demo traffic, shared by the notch
Expand Down
7 changes: 7 additions & 0 deletions Sources/Perch/Model/DetectionInsights.swift
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,13 @@ final class InsightsModel: ObservableObject {
self.store = store
}

/// Showcase support: adopt a prebuilt snapshot without touching the store.
func injectSnapshot(_ snap: DetectionInsightsSnapshot) {
generation += 1 // invalidate any in-flight refresh
snapshot = snap
state = .loaded
}

func refresh(now: Date = Date()) {
generation += 1
let requestGeneration = generation
Expand Down
109 changes: 76 additions & 33 deletions Sources/Perch/UI/InsightsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ private struct InsightsTimelinePoint: Identifiable {

struct InsightsView: View {
@ObservedObject var model: InsightsModel
var renderStatic = false

var body: some View {
VStack(spacing: 0) {
Expand Down Expand Up @@ -75,41 +76,57 @@ struct InsightsView: View {
.controlSize(.small)
.accessibilityLabel("Loading Insights")
}
Picker("Time range", selection: $model.selectedRange) {
ForEach(InsightsRange.allCases) { range in
Text(range.rawValue)
.tag(range)
.accessibilityLabel(range.accessibilityLabel)
if renderStatic {
staticRangePicker
} else {
Picker("Time range", selection: $model.selectedRange) {
ForEach(InsightsRange.allCases) { range in
Text(range.rawValue)
.tag(range)
.accessibilityLabel(range.accessibilityLabel)
}
}
.pickerStyle(.segmented)
.frame(width: 180)
}
.pickerStyle(.segmented)
.frame(width: 180)
Button("Refresh") { model.refresh() }
.disabled(model.state == .loading)
}
}

/// ImageRenderer draws the AppKit-bridged segmented Picker as a missing-
/// view placeholder, so showcase renders draw this SwiftUI stand-in.
private var staticRangePicker: some View {
HStack(spacing: 2) {
ForEach(InsightsRange.allCases) { range in
Text(range.rawValue)
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 3)
.background(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(range == model.selectedRange
? Color.white.opacity(0.22)
: Color.clear))
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color.black.opacity(0.28)))
}

@ViewBuilder
private var content: some View {
if model.state == .unavailable {
unavailableState
} else if let snapshot = model.snapshot {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
summary(snapshot)
timeline(snapshot)
if snapshot.isEmpty {
emptyState
} else {
findings(snapshot)
HStack(alignment: .top, spacing: 14) {
agents(snapshot)
tools(snapshot)
}
sessions(snapshot)
}
if renderStatic {
contentBody(snapshot)
} else {
ScrollView {
contentBody(snapshot)
}
.padding(16)
}
} else {
centeredState(
Expand All @@ -119,6 +136,24 @@ struct InsightsView: View {
}
}

private func contentBody(_ snapshot: DetectionInsightsSnapshot) -> some View {
VStack(alignment: .leading, spacing: 14) {
summary(snapshot)
timeline(snapshot)
if snapshot.isEmpty {
emptyState
} else {
findings(snapshot)
HStack(alignment: .top, spacing: 14) {
agents(snapshot)
tools(snapshot)
}
sessions(snapshot)
}
}
.padding(16)
}

private func summary(_ snapshot: DetectionInsightsSnapshot) -> some View {
HStack(spacing: 12) {
summaryTile(
Expand Down Expand Up @@ -325,17 +360,11 @@ struct InsightsView: View {
.font(.system(size: 11, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(session.findings) { finding in
Text("\(finding.code) ×\(finding.count)")
.font(.system(size: 9, weight: .medium, design: .monospaced))
.foregroundStyle(severityColor(finding.level))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule().fill(severityColor(finding.level).opacity(0.10)))
}
if renderStatic {
findingChips(session)
} else {
ScrollView(.horizontal, showsIndicators: false) {
findingChips(session)
}
}
}
Expand All @@ -347,6 +376,20 @@ struct InsightsView: View {
+ "highest severity \(session.highestLevel.label)")
}

private func findingChips(_ session: DetectionSessionAggregate) -> some View {
HStack(spacing: 5) {
ForEach(session.findings) { finding in
Text("\(finding.code) ×\(finding.count)")
.font(.system(size: 9, weight: .medium, design: .monospaced))
.foregroundStyle(severityColor(finding.level))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule().fill(severityColor(finding.level).opacity(0.10)))
}
}
}

private var emptyState: some View {
HStack(spacing: 10) {
Image(systemName: "checkmark.shield")
Expand Down
Binary file added docs/img/insights.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/integrity.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.