diff --git a/slab/screensaver/.gitignore b/slab/screensaver/.gitignore
new file mode 100644
index 0000000000..30bcfa4ed5
--- /dev/null
+++ b/slab/screensaver/.gitignore
@@ -0,0 +1 @@
+.build/
diff --git a/slab/screensaver/Info.plist b/slab/screensaver/Info.plist
new file mode 100644
index 0000000000..675d0d11b3
--- /dev/null
+++ b/slab/screensaver/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ Slab
+ CFBundleIdentifier
+ computer.slab.screensaver
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Slab
+ CFBundleDisplayName
+ Slab Status
+ CFBundlePackageType
+ BNDL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 13.0
+ NSPrincipalClass
+ SlabSaverView
+ NSHumanReadableCopyright
+ aesthetic.computer · slab
+
+
diff --git a/slab/screensaver/README.md b/slab/screensaver/README.md
new file mode 100644
index 0000000000..801f35b949
--- /dev/null
+++ b/slab/screensaver/README.md
@@ -0,0 +1,47 @@
+# Slab Status screensaver
+
+A macOS `.saver` that shows, fullscreen, the abstract status of our prompts
+plus this machine's system resources. Same visual language as the slab
+menubar icon, scaled up.
+
+## What it shows
+
+- **Rotating polygon** — one edge per live Claude session, colored by that
+ session's state (green working / amber-pulse awaiting / slate complete /
+ gray blank / dim-blink stale). 1 session = a bar, 2 = parallel bars,
+ N≥3 = the matching N-gon. Idle = one slow-breathing ring. Rotation
+ speeds up the more sessions are awaiting you.
+- **Status line + breakdown** — `4 awaiting · 5 active`, with non-zero
+ per-state chips and `ambient` / `muted` mode tags.
+- **System resources** — CPU % with per-core ticks, memory used/total,
+ load average, uptime, clock.
+
+It reads the same on-disk state the menubar reads
+(`~/.local/share/slab/state/{active,awaiting}-prompts`, mode flags) and is
+strictly **read-only** and **subprocess-free** — both required to behave
+inside Apple's sandboxed `legacyScreenSaver` host.
+
+## Build & install
+
+```bash
+./build.sh # compile + install to ~/Library/Screen Savers
+./build.sh --build # compile only (bundle lands in .build/)
+```
+
+No Xcode project — `swiftc` builds the loadable bundle directly
+(`-emit-library -Xlinker -bundle`), so Command Line Tools is enough.
+Ad-hoc signed, which is sufficient for a locally installed saver.
+
+After install, pick it in **System Settings → Screen Saver → Other →
+"Slab Status"** (third-party savers live under the non-Apple group).
+
+## Notes / gotchas
+
+- Inside the screensaver sandbox, `NSHomeDirectory()` / `$HOME` point at
+ the saver's container, not `/Users/`. `RealHome` resolves the true
+ home via `getpwuid(getuid())` so the slab state path is correct.
+- No `Process`/`NSTask`: lid/sleep/tailscale (which the menubar gets by
+ shelling out) are intentionally omitted. CPU/mem/load come from Mach /
+ `sysctl` / `getloadavg`, which are syscalls and work in the sandbox.
+- `legacyScreenSaver` caches loaded bundles; `build.sh` kills it so the
+ next engage / settings-pane open picks up a fresh build.
diff --git a/slab/screensaver/Sources/RealHome.swift b/slab/screensaver/Sources/RealHome.swift
new file mode 100644
index 0000000000..09303d2a80
--- /dev/null
+++ b/slab/screensaver/Sources/RealHome.swift
@@ -0,0 +1,20 @@
+import Foundation
+import Darwin
+
+/// Resolve the *real* user home directory.
+///
+/// The screensaver runs inside Apple's sandboxed `legacyScreenSaver` host,
+/// where `NSHomeDirectory()` / `$HOME` point at a per-saver container
+/// (`~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/…`)
+/// rather than `/Users/`. The slab state lives under the real home, so
+/// we read it from the passwd database (directory-services backed, immune to
+/// the container redirection) instead of the environment.
+enum RealHome {
+ static let path: String = {
+ if let pw = getpwuid(getuid()) {
+ let dir = String(cString: pw.pointee.pw_dir)
+ if !dir.isEmpty { return dir }
+ }
+ return ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
+ }()
+}
diff --git a/slab/screensaver/Sources/SlabSaverView.swift b/slab/screensaver/Sources/SlabSaverView.swift
new file mode 100644
index 0000000000..f4c0639a6b
--- /dev/null
+++ b/slab/screensaver/Sources/SlabSaverView.swift
@@ -0,0 +1,377 @@
+import AppKit
+import ScreenSaver
+
+/// Fullscreen slab status screensaver.
+///
+/// Centerpiece is the same motif as the menubar icon: a slowly rotating
+/// regular polygon with one edge per live Claude session, each edge colored
+/// by that session's state (green working / amber-pulse awaiting / slate
+/// complete / gray blank / dim-blink stale). N=1 is a single bar, N=2 two
+/// parallel bars, N≥3 the matching n-gon. Around it: an abstract numeric
+/// breakdown of the prompts and a system-resource readout (CPU, memory,
+/// load, uptime). No subprocesses, no disk writes — safe in the sandboxed
+/// `legacyScreenSaver` host.
+@objc(SlabSaverView)
+final class SlabSaverView: ScreenSaverView {
+
+ private let metrics = SystemMetrics()
+ private var status = SlabStatus()
+ private var sample = SystemMetrics.Sample()
+ private var phase: CGFloat = 0 // seconds, drives pulses
+ private var rotation: CGFloat = 0 // radians
+ private var frame_: Int = 0
+
+ private let clockFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f
+ }()
+ private let dateFmt: DateFormatter = {
+ let f = DateFormatter(); f.dateFormat = "EEE d MMM"; return f
+ }()
+
+ private let bg = NSColor(deviceWhite: 0.035, alpha: 1)
+
+ override init?(frame: NSRect, isPreview: Bool) {
+ super.init(frame: frame, isPreview: isPreview)
+ animationTimeInterval = 1.0 / 30.0
+ reload()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ animationTimeInterval = 1.0 / 30.0
+ reload()
+ }
+
+ override var isOpaque: Bool { true }
+ override var hasConfigureSheet: Bool { false }
+ override var configureSheet: NSWindow? { nil }
+
+ override func startAnimation() { super.startAnimation() }
+ override func stopAnimation() { super.stopAnimation() }
+
+ private func reload() {
+ status = SlabStatus.gather()
+ sample = metrics.sample()
+ }
+
+ override func animateOneFrame() {
+ let dt = CGFloat(animationTimeInterval)
+ phase += dt
+
+ // Rotate gently while any thread is alive; quicker the more are
+ // waiting on you. Frozen (just breathing) when nothing's live.
+ let speed: CGFloat = status.anyLive
+ ? 0.06 + 0.06 * CGFloat(status.awaiting)
+ : 0
+ rotation += speed * dt
+
+ frame_ += 1
+ if frame_ % 30 == 0 { reload() } // ~1s; also the CPU delta window
+
+ setNeedsDisplay(bounds)
+ }
+
+ // MARK: - Render
+
+ override func draw(_ rect: NSRect) {
+ bg.setFill()
+ rect.fill()
+
+ let w = bounds.width, h = bounds.height
+ let f = max(0.35, min(w / 1680.0, h / 1050.0)) // global scale
+
+ drawHeader(w: w, h: h, f: f)
+ drawPolygon(w: w, h: h, f: f)
+ drawStatusBlock(w: w, h: h, f: f)
+ drawMetrics(w: w, h: h, f: f)
+ drawClock(w: w, h: h, f: f)
+ }
+
+ // MARK: header (wordmark + host)
+
+ private func drawHeader(w: CGFloat, h: CGFloat, f: CGFloat) {
+ let host = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
+ text("slab",
+ at: NSPoint(x: w / 2, y: h - 64 * f),
+ size: 30 * f, weight: .bold,
+ color: NSColor(deviceWhite: 0.82, alpha: 1), center: true)
+ text(host,
+ at: NSPoint(x: w / 2, y: h - 92 * f),
+ size: 13 * f, weight: .regular,
+ color: NSColor(deviceWhite: 0.40, alpha: 1), center: true)
+ }
+
+ // MARK: polygon (one edge per session)
+
+ private let maxSides = 12
+
+ private func drawPolygon(w: CGFloat, h: CGFloat, f: CGFloat) {
+ let cx = w / 2, cy = h * 0.54
+ let radius = min(w, h) * 0.21
+ let lineWidth = max(2.5, radius * 0.035)
+
+ let sessions = status.sessions
+ guard !sessions.isEmpty else {
+ // Idle: a single slow-breathing ring so the canvas isn't dead.
+ let breathe = 0.5 + 0.5 * cos(phase * 0.9)
+ let c = NSColor(deviceHue: 0.58, saturation: 0.22,
+ brightness: 0.32 + 0.20 * breathe, alpha: 1)
+ let ring = NSBezierPath(ovalIn: NSRect(
+ x: cx - radius, y: cy - radius,
+ width: radius * 2, height: radius * 2))
+ ring.lineWidth = lineWidth
+ c.setStroke(); ring.stroke()
+ return
+ }
+
+ let visible = Array(sessions.prefix(maxSides))
+ let n = visible.count
+ let overflow = sessions.count - n
+
+ if n == 1 {
+ segment(center: NSPoint(x: cx, y: cy), length: 2 * radius,
+ angle: rotation, color: color(visible[0].state),
+ width: lineWidth)
+ } else if n == 2 {
+ let half = radius * 0.36
+ let length = radius * 1.8
+ let nx = -sin(rotation), ny = cos(rotation)
+ for i in 0..<2 {
+ let s: CGFloat = i == 0 ? 1 : -1
+ segment(center: NSPoint(x: cx + nx * half * s, y: cy + ny * half * s),
+ length: length, angle: rotation,
+ color: color(visible[i].state), width: lineWidth)
+ }
+ } else {
+ var verts: [NSPoint] = []
+ for k in 0.. 0 {
+ text("+\(overflow)",
+ at: NSPoint(x: cx, y: cy),
+ size: radius * 0.22, weight: .semibold,
+ color: NSColor(deviceWhite: 0.7, alpha: 1), center: true)
+ }
+ }
+
+ private func segment(center: NSPoint, length: CGFloat, angle: CGFloat,
+ color: NSColor, width: CGFloat) {
+ let dx = cos(angle) * length / 2, dy = sin(angle) * length / 2
+ let p = NSBezierPath()
+ p.move(to: NSPoint(x: center.x - dx, y: center.y - dy))
+ p.line(to: NSPoint(x: center.x + dx, y: center.y + dy))
+ p.lineWidth = width
+ p.lineCapStyle = .round
+ color.setStroke(); p.stroke()
+ }
+
+ /// Same palette as the menubar's `IconRenderer.sessionColor`.
+ private func color(_ st: SlabSessionState) -> NSColor {
+ switch st {
+ case .blank:
+ return NSColor(deviceWhite: 0.55, alpha: 1)
+ case .working:
+ return NSColor(deviceHue: 0.33, saturation: 0.70, brightness: 0.78, alpha: 1)
+ case .complete:
+ return NSColor(deviceHue: 0.58, saturation: 0.30, brightness: 0.70, alpha: 1)
+ case .awaiting:
+ let pulse = 0.5 + 0.5 * cos(phase * .pi * 4)
+ return NSColor(deviceHue: 0.10, saturation: 0.95,
+ brightness: 0.70 + 0.30 * pulse, alpha: 1)
+ case .stale:
+ let blink = 0.5 + 0.5 * cos(phase * .pi * 2)
+ return NSColor(deviceWhite: 0.28 + 0.32 * blink, alpha: 1)
+ }
+ }
+
+ // MARK: status line + abstract breakdown
+
+ private func drawStatusBlock(w: CGFloat, h: CGFloat, f: CGFloat) {
+ let cy = h * 0.54
+ let radius = min(w, h) * 0.21
+ let baseY = cy - radius - 56 * f
+
+ let line = status.statusLine
+ let lineColor: NSColor =
+ status.anyAwaiting ? color(.awaiting)
+ : status.anyLive ? color(.working)
+ : NSColor(deviceWhite: 0.45, alpha: 1)
+ text(line, at: NSPoint(x: w / 2, y: baseY),
+ size: 40 * f, weight: .semibold, color: lineColor, center: true)
+
+ // Abstract breakdown: colored count chips, only the non-zero ones.
+ var chips: [(String, NSColor)] = []
+ if status.working > 0 { chips.append(("\(status.working) working", color(.working))) }
+ if status.awaiting > 0 { chips.append(("\(status.awaiting) awaiting", color(.awaiting))) }
+ if status.complete > 0 { chips.append(("\(status.complete) complete", color(.complete))) }
+ if status.blank > 0 { chips.append(("\(status.blank) blank", color(.blank))) }
+ if status.stale > 0 { chips.append(("\(status.stale) stale", color(.stale))) }
+ if status.activeSubagents > 0 {
+ chips.append(("+\(status.activeSubagents) subagents",
+ NSColor(deviceWhite: 0.55, alpha: 1)))
+ }
+
+ if !chips.isEmpty {
+ let size = 16 * f, gap = 26 * f
+ let attrs = monoAttrs(size: size, weight: .medium, color: .white)
+ let widths = chips.map {
+ ($0.0 as NSString).size(withAttributes: attrs).width
+ }
+ let totalW = widths.reduce(0, +) + gap * CGFloat(chips.count - 1)
+ var x = w / 2 - totalW / 2
+ let y = baseY - 34 * f
+ for (i, chip) in chips.enumerated() {
+ text(chip.0, at: NSPoint(x: x, y: y), size: size,
+ weight: .medium, color: chip.1, center: false)
+ x += widths[i] + gap
+ }
+ }
+
+ // Mode tags (ambient / muted), faint, just under the breakdown.
+ var tags: [String] = []
+ if status.ambientActive { tags.append("ambient") }
+ if status.muted { tags.append("muted") }
+ if !tags.isEmpty {
+ text(tags.joined(separator: " · "),
+ at: NSPoint(x: w / 2, y: baseY - 60 * f),
+ size: 13 * f, weight: .regular,
+ color: NSColor(deviceWhite: 0.38, alpha: 1), center: true)
+ }
+ }
+
+ // MARK: system resources (bottom-left)
+
+ private func drawMetrics(w: CGFloat, h: CGFloat, f: CGFloat) {
+ let x = 56 * f
+ let barW = max(220 * f, w * 0.20)
+ var y = 150 * f
+ let dim = NSColor(deviceWhite: 0.42, alpha: 1)
+ let val = NSColor(deviceWhite: 0.78, alpha: 1)
+
+ // LOAD
+ let (l1, l5, l15) = sample.load
+ text("LOAD", at: NSPoint(x: x, y: y), size: 13 * f,
+ weight: .semibold, color: dim, center: false)
+ text(String(format: "%.2f %.2f %.2f", l1, l5, l15),
+ at: NSPoint(x: x + 64 * f, y: y), size: 14 * f,
+ weight: .regular, color: val, center: false)
+ y += 40 * f
+
+ // MEM
+ let memTxt = "\(SystemMetrics.formatBytes(sample.memUsed)) / "
+ + "\(SystemMetrics.formatBytes(sample.memTotal)) GB"
+ labeledBar(label: "MEM", value: memTxt,
+ fraction: sample.memFraction,
+ color: NSColor(deviceHue: 0.58, saturation: 0.45,
+ brightness: 0.78, alpha: 1),
+ x: x, y: y, barW: barW, f: f)
+ y += 56 * f
+
+ // CPU + per-core ticks
+ labeledBar(label: "CPU",
+ value: "\(Int(round(sample.cpuBusy * 100)))%",
+ fraction: sample.cpuBusy,
+ color: cpuColor(sample.cpuBusy),
+ x: x, y: y, barW: barW, f: f)
+ if !sample.perCore.isEmpty {
+ let cores = sample.perCore
+ let cw = max(3 * f, (barW - CGFloat(cores.count - 1) * 2 * f)
+ / CGFloat(cores.count))
+ let maxH = 26 * f
+ let baseY = y + 26 * f
+ var cxp = x
+ for v in cores {
+ let bh = max(1.5 * f, CGFloat(v) * maxH)
+ NSColor(deviceWhite: 0.16, alpha: 1).setFill()
+ NSBezierPath(rect: NSRect(x: cxp, y: baseY, width: cw, height: maxH)).fill()
+ cpuColor(v).setFill()
+ NSBezierPath(rect: NSRect(x: cxp, y: baseY, width: cw, height: bh)).fill()
+ cxp += cw + 2 * f
+ }
+ }
+ }
+
+ private func cpuColor(_ v: Double) -> NSColor {
+ // green → amber → red as load climbs.
+ let hue = 0.33 - 0.33 * CGFloat(min(1, max(0, v)))
+ return NSColor(deviceHue: hue, saturation: 0.75, brightness: 0.82, alpha: 1)
+ }
+
+ private func labeledBar(label: String, value: String, fraction: Double,
+ color: NSColor, x: CGFloat, y: CGFloat,
+ barW: CGFloat, f: CGFloat) {
+ let dim = NSColor(deviceWhite: 0.42, alpha: 1)
+ let val = NSColor(deviceWhite: 0.78, alpha: 1)
+ text(label, at: NSPoint(x: x, y: y), size: 13 * f,
+ weight: .semibold, color: dim, center: false)
+ text(value, at: NSPoint(x: x + 64 * f, y: y), size: 14 * f,
+ weight: .regular, color: val, center: false)
+ let barY = y - 16 * f, barH = 8 * f
+ let track = NSBezierPath(roundedRect:
+ NSRect(x: x, y: barY, width: barW, height: barH),
+ xRadius: barH / 2, yRadius: barH / 2)
+ NSColor(deviceWhite: 0.15, alpha: 1).setFill(); track.fill()
+ let fw = max(barH, barW * CGFloat(min(1, max(0, fraction))))
+ let fill = NSBezierPath(roundedRect:
+ NSRect(x: x, y: barY, width: fw, height: barH),
+ xRadius: barH / 2, yRadius: barH / 2)
+ color.setFill(); fill.fill()
+ }
+
+ // MARK: clock + uptime (bottom-right)
+
+ private func drawClock(w: CGFloat, h: CGFloat, f: CGFloat) {
+ let now = Date()
+ let rx = w - 56 * f
+ text(clockFmt.string(from: now),
+ at: NSPoint(x: rx, y: 150 * f), size: 34 * f,
+ weight: .semibold, color: NSColor(deviceWhite: 0.80, alpha: 1),
+ rightAlign: true)
+ text(dateFmt.string(from: now),
+ at: NSPoint(x: rx, y: 124 * f), size: 14 * f,
+ weight: .regular, color: NSColor(deviceWhite: 0.42, alpha: 1),
+ rightAlign: true)
+ text("up \(SystemMetrics.formatUptime(sample.uptime))",
+ at: NSPoint(x: rx, y: 100 * f), size: 13 * f,
+ weight: .regular, color: NSColor(deviceWhite: 0.34, alpha: 1),
+ rightAlign: true)
+ }
+
+ // MARK: text helpers
+
+ private func monoAttrs(size: CGFloat, weight: NSFont.Weight,
+ color: NSColor) -> [NSAttributedString.Key: Any] {
+ [.font: NSFont.monospacedSystemFont(ofSize: size, weight: weight),
+ .foregroundColor: color]
+ }
+
+ /// Draw mono text. `center` horizontally-centers on `at`; `rightAlign`
+ /// right-justifies to `at.x`; otherwise `at` is the left baseline-ish
+ /// origin. `at.y` is treated as a vertical center.
+ private func text(_ s: String, at: NSPoint, size: CGFloat,
+ weight: NSFont.Weight, color: NSColor,
+ center: Bool = false, rightAlign: Bool = false) {
+ let attrs = monoAttrs(size: size, weight: weight, color: color)
+ let ns = s as NSString
+ let sz = ns.size(withAttributes: attrs)
+ var origin = NSPoint(x: at.x, y: at.y - sz.height / 2)
+ if center { origin.x = at.x - sz.width / 2 }
+ else if rightAlign { origin.x = at.x - sz.width }
+ ns.draw(at: origin, withAttributes: attrs)
+ }
+}
diff --git a/slab/screensaver/Sources/SlabStatus.swift b/slab/screensaver/Sources/SlabStatus.swift
new file mode 100644
index 0000000000..428977db0f
--- /dev/null
+++ b/slab/screensaver/Sources/SlabStatus.swift
@@ -0,0 +1,146 @@
+import Foundation
+import Darwin
+
+/// Per-Claude-session state. Mirrors the menubar's `ClaudeSession.State` so
+/// the screensaver paints the same colors as the menubar polygon icon.
+///
+/// - `blank` SessionStart fired, no prompt yet — a fresh window.
+/// - `working` Claude is actively running a turn.
+/// - `complete` turn finished; alive but idle, no attention needed.
+/// - `awaiting` paused mid-task, needs the user (permission / continue).
+/// - `stale` claude_pid is gone; marker not yet reaped by the menubar.
+enum SlabSessionState {
+ case blank, working, complete, awaiting, stale
+}
+
+struct SlabSession {
+ let sessionId: String
+ let claudePid: Int
+ let updated: Date
+ var state: SlabSessionState
+}
+
+/// Read-only snapshot of "the status of our prompts" plus a couple of slab
+/// mode flags. This is a trimmed port of the menubar's `StateSnapshot` /
+/// `ClaudeSessionReader`: file reads only, **no process spawning** (the
+/// sandboxed screensaver host can't reliably fork) and **no mutation** (the
+/// menubar owns reaping; the screensaver only observes).
+struct SlabStatus {
+ var sessions: [SlabSession] = []
+ var activePrompts: Int = 0
+ var activeSubagents: Int = 0
+ var ambientActive: Bool = false
+ var muted: Bool = false
+ var stateDirReadable: Bool = false
+
+ var working: Int { sessions.filter { $0.state == .working }.count }
+ var awaiting: Int { sessions.filter { $0.state == .awaiting }.count }
+ var complete: Int { sessions.filter { $0.state == .complete }.count }
+ var blank: Int { sessions.filter { $0.state == .blank }.count }
+ var stale: Int { sessions.filter { $0.state == .stale }.count }
+ var anyAwaiting: Bool { awaiting > 0 }
+ var anyLive: Bool { sessions.contains { $0.state != .stale } }
+
+ /// One-line summary, matching the spirit of the menubar's `statusLine`.
+ var statusLine: String {
+ if !stateDirReadable { return "slab" }
+ if anyAwaiting { return "\(awaiting) awaiting · \(sessions.count) active" }
+ if ambientActive && !sessions.isEmpty { return "ambient — \(sessions.count) active" }
+ if ambientActive { return "ambient" }
+ if sessions.isEmpty { return "idle" }
+ return "\(sessions.count) active"
+ }
+
+ private static var stateDir: String { "\(RealHome.path)/.local/share/slab/state" }
+
+ static func gather() -> SlabStatus {
+ var s = SlabStatus()
+ let fm = FileManager.default
+ let activeDir = "\(stateDir)/active-prompts"
+ let awaitingDir = "\(stateDir)/awaiting-prompts"
+
+ s.ambientActive = fm.fileExists(atPath: "/tmp/slab-ambient-active")
+ s.muted = fm.fileExists(atPath: "\(stateDir)/muted")
+ s.activeSubagents = countFiles("\(stateDir)/active-subagents")
+
+ guard let names = try? fm.contentsOfDirectory(atPath: activeDir) else {
+ // Either no work, or the sandbox blocked the read. Either way we
+ // degrade gracefully to a clean idle render.
+ return s
+ }
+ s.stateDirReadable = true
+
+ var awaitingMsgs: [String: String] = [:]
+ if let an = try? fm.contentsOfDirectory(atPath: awaitingDir) {
+ for name in an where !name.hasPrefix(".") {
+ let msg = (try? String(contentsOfFile: "\(awaitingDir)/\(name)", encoding: .utf8))?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ awaitingMsgs[name] = msg.isEmpty ? "awaiting input" : msg
+ }
+ }
+
+ for name in names where !name.hasPrefix(".") {
+ guard let sess = parse(path: "\(activeDir)/\(name)", fallbackId: name) else { continue }
+ var v = sess
+ if v.claudePid > 0 && !pidAlive(v.claudePid) {
+ v.state = .stale // observe only — never unlink
+ } else if let msg = awaitingMsgs[v.sessionId] {
+ v.state = msg.lowercased().hasPrefix("turn complete") ? .complete : .awaiting
+ } else if v.state != .blank {
+ v.state = .working
+ }
+ s.sessions.append(v)
+ }
+ s.activePrompts = s.sessions.count
+
+ // Attention first, then calm, then fresh, then dead — same banding
+ // as the menubar so the polygon edges read consistently.
+ s.sessions.sort { a, b in
+ func rank(_ st: SlabSessionState) -> Int {
+ switch st {
+ case .awaiting: return 0
+ case .complete: return 1
+ case .working: return 2
+ case .blank: return 3
+ case .stale: return 4
+ }
+ }
+ let ra = rank(a.state), rb = rank(b.state)
+ return ra != rb ? ra < rb : a.updated > b.updated
+ }
+ return s
+ }
+
+ private static func countFiles(_ dir: String) -> Int {
+ guard let c = try? FileManager.default.contentsOfDirectory(atPath: dir) else { return 0 }
+ return c.filter { !$0.hasPrefix(".") }.count
+ }
+
+ private static func parse(path: String, fallbackId: String) -> SlabSession? {
+ guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil }
+ let mtime = (try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date) ?? Date()
+ if data.isEmpty {
+ return SlabSession(sessionId: fallbackId, claudePid: 0, updated: mtime, state: .working)
+ }
+ guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
+ let iso = ISO8601DateFormatter()
+ let updated: Date = {
+ if let str = obj["updated"] as? String, let d = iso.date(from: str) { return d }
+ return mtime
+ }()
+ let state: SlabSessionState = ((obj["state"] as? String) == "blank") ? .blank : .working
+ return SlabSession(
+ sessionId: (obj["session_id"] as? String) ?? fallbackId,
+ claudePid: (obj["claude_pid"] as? Int) ?? 0,
+ updated: updated,
+ state: state
+ )
+ }
+
+ /// `kill(pid, 0)` is a bare syscall (no fork), so it's safe inside the
+ /// screensaver sandbox. EPERM means alive-but-not-ours.
+ private static func pidAlive(_ pid: Int) -> Bool {
+ guard pid > 0 else { return false }
+ return kill(pid_t(pid), 0) == 0 || errno == EPERM
+ }
+}
diff --git a/slab/screensaver/Sources/SystemMetrics.swift b/slab/screensaver/Sources/SystemMetrics.swift
new file mode 100644
index 0000000000..bbcf9204c5
--- /dev/null
+++ b/slab/screensaver/Sources/SystemMetrics.swift
@@ -0,0 +1,126 @@
+import Foundation
+import Darwin
+
+/// In-process system-resource sampler. Everything here is a Mach / sysctl
+/// call — no subprocesses — so it works inside the sandboxed screensaver
+/// host. Keep one instance alive across frames: CPU % is a delta between
+/// successive `host_processor_info` reads.
+final class SystemMetrics {
+ struct Sample {
+ var cpuBusy: Double = 0 // 0…1 aggregate
+ var perCore: [Double] = [] // 0…1 per logical core
+ var memUsed: UInt64 = 0 // bytes (active + wired + compressed)
+ var memTotal: UInt64 = 0 // bytes
+ var load: (Double, Double, Double) = (0, 0, 0)
+ var uptime: TimeInterval = 0 // seconds since boot
+
+ var memFraction: Double {
+ memTotal == 0 ? 0 : min(1, Double(memUsed) / Double(memTotal))
+ }
+ }
+
+ private var prevTicks: [(user: UInt32, sys: UInt32, idle: UInt32, nice: UInt32)] = []
+ private let memTotal: UInt64 = {
+ var size: UInt64 = 0
+ var len = MemoryLayout.size
+ sysctlbyname("hw.memsize", &size, &len, nil, 0)
+ return size
+ }()
+
+ func sample() -> Sample {
+ var s = Sample()
+ s.memTotal = memTotal
+ s.uptime = ProcessInfo.processInfo.systemUptime
+
+ var loads = [Double](repeating: 0, count: 3)
+ if getloadavg(&loads, 3) == 3 {
+ s.load = (loads[0], loads[1], loads[2])
+ }
+
+ sampleMemory(into: &s)
+ sampleCPU(into: &s)
+ return s
+ }
+
+ private func sampleMemory(into s: inout Sample) {
+ var stats = vm_statistics64()
+ var count = mach_msg_type_number_t(
+ MemoryLayout.stride / MemoryLayout.stride)
+ let kr = withUnsafeMutablePointer(to: &stats) {
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
+ }
+ }
+ guard kr == KERN_SUCCESS else { return }
+ let page = UInt64(vm_kernel_page_size)
+ let used = UInt64(stats.active_count)
+ + UInt64(stats.wire_count)
+ + UInt64(stats.compressor_page_count)
+ s.memUsed = used * page
+ }
+
+ private func sampleCPU(into s: inout Sample) {
+ var cpuInfo: processor_info_array_t?
+ var numInfo: mach_msg_type_number_t = 0
+ var numCPUs: natural_t = 0
+ let kr = host_processor_info(
+ mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUs, &cpuInfo, &numInfo)
+ guard kr == KERN_SUCCESS, let info = cpuInfo else { return }
+ defer {
+ vm_deallocate(mach_task_self_,
+ vm_address_t(bitPattern: info),
+ vm_size_t(numInfo) * vm_size_t(MemoryLayout.stride))
+ }
+
+ let n = Int(numCPUs)
+ var cur: [(UInt32, UInt32, UInt32, UInt32)] = []
+ cur.reserveCapacity(n)
+ for i in 0.. 0 ? min(1, busy / total) : 0)
+ busyAcc += busy
+ totalAcc += total
+ }
+ prevTicks = cur.map { (user: $0.0, sys: $0.1, idle: $0.2, nice: $0.3) }
+ s.perCore = per
+ s.cpuBusy = totalAcc > 0 ? min(1, busyAcc / totalAcc) : 0
+ }
+
+ static func formatBytes(_ bytes: UInt64) -> String {
+ let gb = Double(bytes) / 1_073_741_824.0
+ return String(format: "%.1f", gb)
+ }
+
+ static func formatUptime(_ secs: TimeInterval) -> String {
+ let t = Int(secs)
+ let d = t / 86400, h = (t % 86400) / 3600, m = (t % 3600) / 60
+ if d > 0 { return "\(d)d \(h)h" }
+ if h > 0 { return "\(h)h \(m)m" }
+ return "\(m)m"
+ }
+}
diff --git a/slab/screensaver/build.sh b/slab/screensaver/build.sh
new file mode 100755
index 0000000000..4d1dadd445
--- /dev/null
+++ b/slab/screensaver/build.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# build.sh — compile and install the Slab status screensaver.
+#
+# Produces a loadable `.saver` bundle with swiftc directly (no Xcode
+# project needed — Command Line Tools is enough) and drops it into
+# ~/Library/Screen Savers/. Idempotent; safe to re-run after edits.
+#
+# ./build.sh build + install
+# ./build.sh --build build only (assemble bundle, don't install)
+
+set -euo pipefail
+
+CYAN=$'\033[1;36m'; GREEN=$'\033[1;32m'; YELLOW=$'\033[1;33m'; RESET=$'\033[0m'
+say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; }
+ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; }
+warn(){ printf "%s! %s%s\n" "$YELLOW" "$1" "$RESET"; }
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+command -v swiftc >/dev/null 2>&1 || {
+ echo "swiftc not found — install Command Line Tools: xcode-select --install"
+ exit 1
+}
+
+SDK="$(xcrun --sdk macosx --show-sdk-path)"
+ARCH="$(uname -m)"
+TARGET="${ARCH}-apple-macos13.0"
+BUILD="$SCRIPT_DIR/.build"
+SAVER="$BUILD/Slab.saver"
+MACOS_DIR="$SAVER/Contents/MacOS"
+RES_DIR="$SAVER/Contents/Resources"
+
+say "compiling Slab.saver ($ARCH, swift $(swiftc --version | head -1 | awk '{print $4}'))"
+rm -rf "$SAVER"
+mkdir -p "$MACOS_DIR" "$RES_DIR"
+
+# `-emit-library` puts swiftc in library-link mode (no implicit main);
+# `-Xlinker -bundle` flips the Mach-O type from dylib to MH_BUNDLE, which
+# is what NSBundle's principalClass loader expects from a `.saver`.
+# `-swift-version 5` relaxes Swift-6 strict concurrency for the AppKit
+# overrides.
+swiftc \
+ -emit-library \
+ -o "$MACOS_DIR/Slab" \
+ -module-name Slab \
+ -swift-version 5 \
+ -target "$TARGET" \
+ -sdk "$SDK" \
+ -O \
+ -framework ScreenSaver \
+ -framework AppKit \
+ -framework Foundation \
+ -Xlinker -bundle \
+ Sources/*.swift
+
+if ! file "$MACOS_DIR/Slab" | grep -q "bundle"; then
+ warn "expected a Mach-O bundle; got: $(file "$MACOS_DIR/Slab")"
+fi
+
+cp "$SCRIPT_DIR/Info.plist" "$SAVER/Contents/Info.plist"
+ok "bundle assembled: $SAVER"
+
+# Ad-hoc signing — sufficient for a locally built saver in ~/Library.
+codesign --force --deep --sign - \
+ --identifier computer.slab.screensaver \
+ "$SAVER" >/dev/null 2>&1 \
+ && ok "ad-hoc signed" \
+ || warn "codesign failed (saver may still load locally)"
+
+if [[ "${1:-}" == "--build" ]]; then
+ printf "\n%sbuilt only.%s bundle: %s\n" "$GREEN" "$RESET" "$SAVER"
+ exit 0
+fi
+
+DEST_DIR="$HOME/Library/Screen Savers"
+DEST="$DEST_DIR/Slab.saver"
+say "installing → $DEST"
+mkdir -p "$DEST_DIR"
+
+# legacyScreenSaver caches loaded bundles; killing it forces a fresh load
+# next time the screensaver engages or the System Settings pane opens.
+rm -rf "$DEST"
+cp -R "$SAVER" "$DEST"
+ok "installed"
+
+pkill -x legacyScreenSaver 2>/dev/null || true
+killall ScreenSaverEngine 2>/dev/null || true
+
+printf "\n%sdone.%s\n" "$GREEN" "$RESET"
+echo " bundle: $DEST"
+echo
+echo " Select it: System Settings → Screen Saver → Other → \"Slab Status\""
+echo " (it appears under the 'Other' / non-Apple group)"
+echo " Preview now: open the Screen Saver settings pane and pick it."