From 0cfdfc7a6f40062a6c7b435982bb86a4d19ae12b Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 23 Jul 2026 00:45:24 +0000 Subject: [PATCH] Add fleet resource graph and Iris action trails --- slab/blueberry-cabinet/Info.plist | 22 ++++++++++++++++++++++ slab/blueberry-cabinet/README.md | 17 +++++++++++++++++ slab/blueberry-cabinet/Sources/main.swift | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/blueberry-cabinet/blueberry.cfg | 20 ++++++++++++++++++++ slab/blueberry-cabinet/install.sh | 17 +++++++++++++++++ slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift | 38 +++++++++++++++++++++++++++++++++++++- slab/menubar-swift/Sources/SlabMenubar/Ledger.swift | 7 +++++++ slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift | 6 ++++++ slab/menubar-swift/Sources/SlabMenubar/Paths.swift | 2 ++ slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------ slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift | 54 +++++++++++++++++++++++++++++++----------------------- slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ toolchain/macos/fleet/README.md | 27 +++++++++++---------------- toolchain/macos/fleet/blueberry-join.sh | 34 ++++------------------------------ toolchain/macos/fleet/stats-sync.sh | 3 +++ toolchain/mcp/iris-mcp.mjs | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ toolchain/mcp/iris-mcp.test.mjs | 35 +++++++++++++++++++++++++++++++++++ 17 file(s) changed, 906 insertion(s)(+), 142 deletion(s)(-) diff --git a/slab/blueberry-cabinet/Info.plist b/slab/blueberry-cabinet/Info.plist new file mode 100644 --- /dev/null +++ b/slab/blueberry-cabinet/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleExecutable + BlueberryCabinet + CFBundleIdentifier + computer.aesthetic.blueberry-cabinet + CFBundleName + Blueberry Cabinet + CFBundleDisplayName + Blueberry Cabinet + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + + diff --git a/slab/blueberry-cabinet/README.md b/slab/blueberry-cabinet/README.md new file mode 100644 --- /dev/null +++ b/slab/blueberry-cabinet/README.md @@ -0,0 +1,17 @@ +# Blueberry Cabinet + +A native AppKit cabinet surround for MAME on Blueberry. It launches Street +Fighter II: Champion Edition in a fixed, nearest-neighbor-scaled window and +keeps the QWERTY control map visible on the cabinet deck. + +```zsh +slab/blueberry-cabinet/install.sh +open "$HOME/Applications/Blueberry Cabinet.app" +``` + +The cabinet and MAME are separate native windows. MAME sits over the bezel's +screen aperture; closing MAME closes the cabinet. + +Controls live in `~/Arcade/ctrlr/blueberry.cfg`, a persistent controller +profile loaded with `-ctrlr blueberry`. MAME never rewrites this file as part +of its per-game configuration lifecycle. diff --git a/slab/blueberry-cabinet/Sources/main.swift b/slab/blueberry-cabinet/Sources/main.swift new file mode 100644 --- /dev/null +++ b/slab/blueberry-cabinet/Sources/main.swift @@ -0,0 +1,205 @@ +import AppKit +import CoreGraphics + +private let cabinetWidth: CGFloat = 920 +private let cabinetHeight: CGFloat = 720 +private let screenWidth: CGFloat = 768 +private let screenHeight: CGFloat = 448 +private let screenLeft: CGFloat = 76 +private let screenTop: CGFloat = 92 + +final class CabinetView: NSView { + override var isFlipped: Bool { true } + + private func rounded(_ rect: NSRect, radius: CGFloat, color: NSColor) { + color.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + } + + private func text(_ value: String, at point: NSPoint, size: CGFloat, + color: NSColor = .white, weight: NSFont.Weight = .bold) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + ] + value.draw(at: point, withAttributes: attributes) + } + + private func centered(_ value: String, y: CGFloat, size: CGFloat, + color: NSColor = .white, weight: NSFont.Weight = .bold) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: weight), + .foregroundColor: color, + ] + let width = value.size(withAttributes: attributes).width + value.draw(at: NSPoint(x: (bounds.width - width) / 2, y: y), withAttributes: attributes) + } + + private func key(_ label: String, x: CGFloat, y: CGFloat, color: NSColor) { + rounded(NSRect(x: x, y: y, width: 42, height: 36), radius: 9, color: color) + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 17, weight: .heavy), + .foregroundColor: NSColor.white, + ] + let size = label.size(withAttributes: attributes) + label.draw(at: NSPoint(x: x + (42 - size.width) / 2, y: y + 7), withAttributes: attributes) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let bg = NSGradient(colors: [ + NSColor(calibratedRed: 0.035, green: 0.055, blue: 0.12, alpha: 1), + NSColor(calibratedRed: 0.02, green: 0.025, blue: 0.055, alpha: 1), + ])! + bg.draw(in: bounds, angle: -90) + + // Marquee. + rounded(NSRect(x: 24, y: 18, width: bounds.width - 48, height: 62), radius: 18, + color: NSColor(calibratedRed: 0.08, green: 0.20, blue: 0.48, alpha: 1)) + centered("BLUEBERRY FIGHT CLUB", y: 31, size: 28, + color: NSColor(calibratedRed: 0.65, green: 0.88, blue: 1, alpha: 1), weight: .black) + + // Bezelβ€”the live MAME window sits over the black aperture. + rounded(NSRect(x: screenLeft - 18, y: screenTop - 18, + width: screenWidth + 36, height: screenHeight + 36), radius: 22, + color: NSColor(calibratedWhite: 0.015, alpha: 1)) + rounded(NSRect(x: screenLeft, y: screenTop, width: screenWidth, height: screenHeight), + radius: 4, color: .black) + + // Control deck. + rounded(NSRect(x: 32, y: 566, width: bounds.width - 64, height: 126), radius: 22, + color: NSColor(calibratedRed: 0.055, green: 0.085, blue: 0.16, alpha: 1)) + text("MOVE", at: NSPoint(x: 64, y: 582), size: 12, + color: NSColor(calibratedWhite: 0.62, alpha: 1)) + key("W", x: 117, y: 576, color: .systemBlue) + key("A", x: 68, y: 618, color: .systemBlue) + key("S", x: 117, y: 618, color: .systemBlue) + key("D", x: 166, y: 618, color: .systemBlue) + + text("PUNCH", at: NSPoint(x: 286, y: 582), size: 12, + color: NSColor(calibratedWhite: 0.62, alpha: 1)) + key("J", x: 286, y: 608, color: .systemPink) + key("K", x: 336, y: 608, color: .systemPink) + key("L", x: 386, y: 608, color: .systemPink) + + text("KICK", at: NSPoint(x: 286, y: 654), size: 12, + color: NSColor(calibratedWhite: 0.62, alpha: 1)) + key("N", x: 336, y: 650, color: .systemOrange) + key("M", x: 386, y: 650, color: .systemOrange) + key(",", x: 436, y: 650, color: .systemOrange) + + key("↩", x: 546, y: 608, color: .systemGreen) + text("COIN", at: NSPoint(x: 547, y: 654), size: 11, + color: NSColor(calibratedWhite: 0.7, alpha: 1)) + key("1", x: 612, y: 608, color: .systemPurple) + text("START", at: NSPoint(x: 610, y: 654), size: 11, + color: NSColor(calibratedWhite: 0.7, alpha: 1)) + + rounded(NSRect(x: 708, y: 592, width: 154, height: 66), radius: 14, + color: NSColor(calibratedRed: 0.03, green: 0.14, blue: 0.10, alpha: 1)) + text("PERFORMANCE", at: NSPoint(x: 724, y: 603), size: 10, + color: NSColor(calibratedWhite: 0.65, alpha: 1)) + text("18.6Γ— HEADROOM", at: NSPoint(x: 724, y: 625), size: 15, + color: .systemGreen, weight: .heavy) + + centered("TAB settings ESC quit", y: 698, size: 11, + color: NSColor(calibratedWhite: 0.48, alpha: 1), weight: .medium) + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private var window: NSWindow! + private var mame: Process? + private var isClosing = false + + func applicationDidFinishLaunching(_ notification: Notification) { + guard let visible = NSScreen.main?.visibleFrame else { + NSApp.terminate(nil) + return + } + + let frame = NSRect( + x: visible.midX - cabinetWidth / 2, + y: visible.midY - cabinetHeight / 2, + width: cabinetWidth, + height: cabinetHeight + ) + + window = NSWindow(contentRect: frame, + styleMask: [.borderless], + backing: .buffered, + defer: false) + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.level = .normal + window.ignoresMouseEvents = true + window.collectionBehavior = [.fullScreenAuxiliary] + window.contentView = CabinetView(frame: NSRect(origin: .zero, size: frame.size)) + window.delegate = self + window.orderFrontRegardless() + + launchMAME(cabinetFrame: frame, screenFrame: NSScreen.main!.frame) + } + + private func launchMAME(cabinetFrame: NSRect, screenFrame: NSRect) { + // SDL uses a top-left display origin; AppKit uses bottom-left. + let gameX = Int(cabinetFrame.minX + screenLeft) + let gameTopInAppKit = cabinetFrame.maxY - screenTop + let gameY = Int(screenFrame.maxY - gameTopInAppKit) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/mame") + process.environment = ProcessInfo.processInfo.environment.merging([ + "SDL_VIDEO_WINDOW_POS": "\(gameX),\(gameY)", + ]) { _, new in new } + let home = FileManager.default.homeDirectoryForCurrentUser.path + process.arguments = [ + "-rompath", "\(home)/Arcade/roms", + "-cfg_directory", "\(home)/Arcade/cfg", + "-nvram_directory", "\(home)/Arcade/nvram", + "-state_directory", "\(home)/Arcade/states", + "-snapshot_directory", "\(home)/Arcade/snaps", + "-ctrlrpath", "\(home)/Arcade/ctrlr", + "-ctrlr", "blueberry", + "-pluginspath", "/opt/homebrew/share/mame/plugins", + "-plugin", "hiscore", + "-joystick", "-skip_gameinfo", "-window", "-nomaximize", "-nofilter", + "-resolution", "\(Int(screenWidth))x\(Int(screenHeight))", + "sf2ce", + ] + process.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + guard let self, !self.isClosing else { return } + self.isClosing = true + NSApp.terminate(nil) + } + } + do { + try process.run() + mame = process + } catch { + let alert = NSAlert(error: error) + alert.runModal() + NSApp.terminate(nil) + } + } + + func windowWillClose(_ notification: Notification) { + isClosing = true + if let mame, mame.isRunning { mame.terminate() } + NSApp.terminate(nil) + } + + func applicationWillTerminate(_ notification: Notification) { + isClosing = true + if let mame, mame.isRunning { mame.terminate() } + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.regular) +app.run() diff --git a/slab/blueberry-cabinet/blueberry.cfg b/slab/blueberry-cabinet/blueberry.cfg new file mode 100644 --- /dev/null +++ b/slab/blueberry-cabinet/blueberry.cfg @@ -0,0 +1,20 @@ + + + + + + KEYCODE_W + KEYCODE_S + KEYCODE_A + KEYCODE_D + KEYCODE_J + KEYCODE_K + KEYCODE_L + KEYCODE_N + KEYCODE_M + KEYCODE_COMMA + KEYCODE_1 + KEYCODE_ENTER + + + diff --git a/slab/blueberry-cabinet/install.sh b/slab/blueberry-cabinet/install.sh new file mode 100644 --- /dev/null +++ b/slab/blueberry-cabinet/install.sh @@ -0,0 +1,17 @@ +#!/bin/zsh + +set -eu + +HERE="${0:A:h}" +BUILD="$HERE/build" +APP="$HOME/Applications/Blueberry Cabinet.app" + +mkdir -p "$BUILD" "$APP/Contents/MacOS" +mkdir -p "$HOME/Arcade/ctrlr" +swiftc -O -framework AppKit -framework CoreGraphics \ + "$HERE/Sources/main.swift" -o "$BUILD/BlueberryCabinet" +cp "$BUILD/BlueberryCabinet" "$APP/Contents/MacOS/BlueberryCabinet" +cp "$HERE/Info.plist" "$APP/Contents/Info.plist" +cp "$HERE/blueberry.cfg" "$HOME/Arcade/ctrlr/blueberry.cfg" +codesign --force --deep --sign - "$APP" +echo "Installed $APP" diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -168,6 +168,7 @@ // mutating it while it's on screen. menu.autoenablesItems = false menu.delegate = self statusItem.menu = menu + ResourceGraph.shared.syncEnabled() do { try passphraseServer.start() @@ -193,6 +194,18 @@ self?.refreshSignalCount() } imsgTimer = contactTimer RunLoop.main.add(contactTimer, forMode: .common) + + NotificationCenter.default.addObserver( + forName: LedgerStore.promptLaunchedNote, object: nil, queue: .main + ) { [weak self] _ in + guard let self, self.state.autoTile else { return } + // `open -a Terminal` returns before the new window has acquired + // its final AX frame. One delayed tile plus the tiler's own settle + // passes gives it a proper grid-sized cell (especially height). + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.tileNowImpl(resetZoom: false) + } + } // Title-component hygiene for the Slab-* Terminal profiles: the // working-dir and active-process checkboxes are NOT scriptable and @@ -328,6 +341,7 @@ if ZoomLens.isZoomed { ZoomLens.zoomOut() } passphraseServer.stop() LedgerStore.shared.stop() NotificationCenter.default.removeObserver(self) + ResourceGraph.shared.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) } @@ -999,6 +1013,15 @@ } } NSLog("πŸͺ¨ [prox] %@ poked + starting shared wake on %@", String(sid.prefix(8)), tty) + // A Loopboy terminal may have been launched at Terminal.app's tall + // default size or survived an older wall layout. When auto-tile is on, + // normalize the whole wall after the wake so the client prompt returns + // to a real grid cell without the focus-stealing font reset. + if state.autoTile { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + self?.tileNowImpl(resetZoom: false) + } + } } private func ttyForSession(_ sid: String) -> String? { @@ -1701,6 +1724,10 @@ ShellRunner.runAsync("\(Paths.slabBin)/slab-fade-ambient", args: ["--kill-slab-afplay"]) } refresh() + } + + @objc func toggleResourceGraph() { + ResourceGraph.shared.toggle() } @objc func syncBoth() { syncMail(account: nil) } @@ -3357,6 +3384,7 @@ self?.lastTiledFontSize = pass.fontSize // Reset decor memo so the next refresh re-themes every // window from scratch (a re-pack invalidates prior placement). self?.lastTerminalDecor.removeAll() + PromptSigilOverlayController.shared.terminalsDidRetile() } // Geometry is already done β€” the grid snapped above. Terminal // text size catches up asynchronously, and only when needed: @@ -3399,9 +3427,13 @@ // wins. The windows already snapped instantly in the first pass // above; these are tiny AX corrections (sub-ms, no focus steal), // so it stays snappy while resolving cleanly after the reflow. Self.axTilePass(geom: geom, textSize: textSize) + DispatchQueue.main.async { + PromptSigilOverlayController.shared.terminalsDidRetile() + } for delay in [0.06, 0.16] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { Self.axTilePass(geom: geom, textSize: textSize) + PromptSigilOverlayController.shared.terminalsDidRetile() } } } @@ -3608,7 +3640,11 @@ lines.append("end tell") } guard !lines.isEmpty else { return } let script = lines.joined(separator: "\n") - ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) + ShellRunner.runAsync("/usr/bin/osascript", args: ["-e", script]) { + DispatchQueue.main.async { + PromptSigilOverlayController.shared.terminalsDidRetile() + } + } } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -77,6 +77,10 @@ /// Posted on the main queue when prox asks this host to re-enter a live /// prompt. AppDelegate handles it through the exact same guarded terminal /// wake primitive used by Loopboy heartbeats. static let wakeNote = Notification.Name("slab.ledger.wake") + /// Posted after Terminal accepts a prox/Loopboy prompt launch. The app + /// waits briefly for the new window, then normalizes the wall so Terminal's + /// tall default frame never survives as a special case. + static let promptLaunchedNote = Notification.Name("slab.ledger.prompt-launched") // ── on-disk layout (kept: survives restarts) ───────────────────────── static var dir: String { "\(Paths.home)/.config/slab/ledger" } @@ -242,6 +246,9 @@ return ["ok": false, "error": detail?.isEmpty == false ? detail! : "Terminal.app rejected launch"] } let by = String(((body["by"] as? String) ?? "prox").prefix(100)) NSLog("πŸͺ¨ [ledger] %@ launched %@ in %@", by, agent, cwd) + DispatchQueue.main.async { + NotificationCenter.default.post(name: Self.promptLaunchedNote, object: nil) + } return ["ok": true, "host": selfIdentity().host, "agent": agent, "cwd": cwd, "loopboyContact": loopboyContact, "nudgeScreen": nudgeScreen] } diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -142,6 +142,12 @@ mac.addItem(mute) menu.addItem(section("Mac", symbol: "desktopcomputer", submenu: mac)) let slab = NSMenu() + let graph = item("Resource graph (RAM Β· SSD Β· GPU Β· CPU Β· network)", + selector: #selector(AppDelegate.toggleResourceGraph), target: target) + graph.state = ResourceGraph.shared.enabled ? .on : .off + graph.toolTip = "Show a compact Slab-owned five-channel history graph in the menu bar." + slab.addItem(graph) + slab.addItem(.separator()) slab.addItem(item("Open daemon log", selector: #selector(AppDelegate.openDaemonLog), target: target)) slab.addItem(item("Open sounds folder", selector: #selector(AppDelegate.openSoundsFolder), target: target)) slab.addItem(.separator()) diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -153,6 +153,8 @@ /// Persistent mute flag. When this file exists, claude-stop.sh skips /// chimes and stops ambient instead of starting it. Toggled from the /// menubar's "Mute ambient sonification" item. static var muteFlag: String { "\(slabHome)/state/muted" } + /// Opt-in compact resource graph status item (RAM, SSD, GPU, CPU, network). + static var resourceGraphFlag: String { "\(slabHome)/state/resource-graph" } /// When this file exists, restored / restarted Claude windows are /// auto-tiled across the main display in a grid sized by the window /// count, with the Terminal font scaled so no cell is too cramped. diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift --- a/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptFocusHighlight.swift @@ -12,12 +12,13 @@ /// A tiny render-server-driven particle field. Swift updates geometry/theme /// only when the prompt-rock controller already ticks; Core Animation moves /// the particles without a per-frame app timer. private final class PromptParticleView: NSView { - private let glow = CAShapeLayer() - private let emitter = CAEmitterLayer() - private var source = CGRect.zero + private let emitters = (0..<4).map { _ in CAEmitterLayer() } private var color = NSColor.systemGreen private var installed = false private var active = false + private var distributionSeed: UInt64 = 0 + private var density: Float = 1 + private var edgeWeights = [Float](repeating: 0.25, count: 4) var emissionRate: Float = 7 { didSet { applyBirthRate() } } var isEmitting: Bool { get { active } @@ -30,71 +31,111 @@ installed = true root.isGeometryFlipped = false root.masksToBounds = true - glow.shadowOpacity = 0.70 - glow.shadowRadius = 9 - glow.shadowOffset = .zero - glow.actions = ["path": NSNull(), "fillColor": NSNull(), - "shadowColor": NSNull()] - root.addSublayer(glow) - - emitter.emitterShape = .line - emitter.emitterMode = .surface - emitter.renderMode = .additive - emitter.actions = ["frame": NSNull(), "position": NSNull(), - "bounds": NSNull(), "emitterPosition": NSNull(), - "emitterSize": NSNull(), "birthRate": NSNull()] - root.addSublayer(emitter) + for emitter in emitters { + // A `.line` source only honors emitterSize.width, which collapses + // vertical left/right sources to a point. A 1–3 px rectangle works + // as the same edge source in both orientations. + emitter.emitterShape = .rectangle + emitter.emitterMode = .surface + emitter.renderMode = .additive + emitter.actions = ["frame": NSNull(), "position": NSNull(), + "bounds": NSNull(), "emitterPosition": NSNull(), + "emitterSize": NSNull(), "birthRate": NSNull()] + root.addSublayer(emitter) + } updateCells() } - func configure(subject: CGRect, color nextColor: NSColor) { - source = CGRect(x: subject.minX + 12, y: subject.minY - 2, - width: max(12, subject.width - 24), height: 3) - if nextColor != color { + func configure(subject: CGRect, color nextColor: NSColor, seed: UInt64) { + if nextColor != color || seed != distributionSeed { color = nextColor + distributionSeed = seed + density = Float(0.82 + unit(lane: 90) * 0.36) updateCells() } + let inset: CGFloat = 12 + let sources = [ + CGRect(x: subject.minX + inset, y: subject.minY - 2, + width: max(12, subject.width - inset * 2), height: 3), + CGRect(x: subject.maxX - 1, y: subject.minY + inset, + width: 3, height: max(12, subject.height - inset * 2)), + CGRect(x: subject.minX + inset, y: subject.maxY - 1, + width: max(12, subject.width - inset * 2), height: 3), + CGRect(x: subject.minX - 2, y: subject.minY + inset, + width: 3, height: max(12, subject.height - inset * 2)), + ] + let lengths = sources.enumerated().map { $0.offset % 2 == 0 ? $0.element.width : $0.element.height } + let perimeter = max(1, lengths.reduce(0, +)) + edgeWeights = lengths.map { Float($0 / perimeter) } CATransaction.begin() CATransaction.setDisableActions(true) - glow.frame = bounds - glow.path = CGPath(roundedRect: source, cornerWidth: 1.5, - cornerHeight: 1.5, transform: nil) - glow.fillColor = color.withAlphaComponent(0.42).cgColor - glow.shadowColor = color.cgColor - emitter.frame = bounds - emitter.emitterPosition = CGPoint(x: source.midX, y: source.minY) - emitter.emitterSize = CGSize(width: source.width, height: 1) + for (i, source) in sources.enumerated() { + let emitter = emitters[i] + emitter.frame = bounds + emitter.emitterPosition = CGPoint(x: source.midX, y: source.midY) + emitter.emitterSize = CGSize(width: source.width, height: source.height) + } CATransaction.commit() + applyBirthRate() } private func applyBirthRate() { - emitter.birthRate = active ? emissionRate : 0 + for (i, emitter) in emitters.enumerated() { + emitter.birthRate = active ? emissionRate * density * edgeWeights[i] : 0 + } + } + + /// SplitMix64 gives each prox-name seed stable, well-separated controls + /// without turning the animation itself into a repeating canned sequence. + private func unit(lane: UInt64) -> CGFloat { + var z = distributionSeed &+ lane &* 0x9E3779B97F4A7C15 + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + z ^= z >> 31 + return CGFloat(z & 0xFFFF) / CGFloat(0xFFFF) } private func updateCells() { - let cell = CAEmitterCell() - cell.contents = Self.particleImage - cell.color = color.withAlphaComponent(0.84).cgColor - cell.birthRate = 1 - cell.lifetime = 1.85 - cell.lifetimeRange = 0.55 - cell.velocity = 27 - cell.velocityRange = 11 - // Radiate from the prompt in every direction; a light downward pull - // keeps the field attached to the window without collapsing it back - // into the narrow one-way stream this replaced. - cell.yAcceleration = -8 - cell.emissionLongitude = 0 - cell.emissionRange = .pi * 2 - cell.scale = 0.43 - cell.scaleRange = 0.16 - cell.scaleSpeed = -0.10 - cell.alphaSpeed = -0.39 - emitter.emitterCells = [cell] + // AppKit layer coordinates are y-up here. Give every edge one strict + // outward vector and keep accelerating along it, so this reads as four + // directional sprays rather than a perimeter of drifting sparkles. + let vectors: [(angle: CGFloat, dx: CGFloat, dy: CGFloat)] = [ + (-.pi / 2, 0, -1), // bottom β†’ down + (0, 1, 0), // right β†’ right + (.pi / 2, 0, 1), // top β†’ up + (.pi, -1, 0), // left β†’ left + ] + for (i, emitter) in emitters.enumerated() { + let vector = vectors[i] + let lane = UInt64(i * 12) + let acceleration = 26 + unit(lane: lane + 1) * 28 + let cell = CAEmitterCell() + cell.contents = Self.particleImage + cell.color = color.withAlphaComponent(0.84).cgColor + cell.birthRate = 1 + cell.lifetime = Float(1.7 + unit(lane: lane + 2) * 0.8) + cell.lifetimeRange = Float(0.55 + unit(lane: lane + 3) * 0.65) + cell.velocity = 29 + unit(lane: lane + 4) * 21 + cell.velocityRange = 13 + unit(lane: lane + 5) * 18 + cell.xAcceleration = vector.dx * acceleration + cell.yAcceleration = vector.dy * acceleration + cell.emissionLongitude = vector.angle + (unit(lane: lane + 6) - 0.5) * 0.42 + // Broad but still outward-facing: random tangential motion makes + // the perimeter feel turbulent without sending particles back + // through the terminal that emitted them. + cell.emissionRange = 0.72 + unit(lane: lane + 7) * 0.72 + cell.scale = 0.32 + unit(lane: lane + 8) * 0.23 + cell.scaleRange = 0.16 + unit(lane: lane + 9) * 0.18 + cell.scaleSpeed = -(0.04 + unit(lane: lane + 10) * 0.10) + cell.spin = 0 + cell.spinRange = .pi * 2 + cell.alphaSpeed = -Float(0.25 + unit(lane: lane + 11) * 0.22) + emitter.emitterCells = [cell] + } } private static let particleImage: CGImage? = { - let size = CGSize(width: 12, height: 16) + let size = CGSize(width: 12, height: 12) guard let context = CGContext( data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: Int(size.width) * 4, @@ -106,7 +147,6 @@ colors: [NSColor.white.cgColor, NSColor.white.withAlphaComponent(0.5).cgColor, NSColor.clear.cgColor] as CFArray, locations: [0, 0.36, 1]) else { return nil } - context.scaleBy(x: 1, y: size.height / size.width) let center = CGPoint(x: size.width / 2, y: size.width / 2) context.drawRadialGradient( gradient, startCenter: center, startRadius: 0, @@ -138,9 +178,6 @@ /// The selected prompt emits a little more strongly, but no outline is drawn. final class PromptFocusHighlight { static let shared = PromptFocusHighlight() - private static let horizontalPad: CGFloat = 14 - private static let dropDepth: CGFloat = 130 - private static let topOverlap: CGFloat = 12 private var effects: [Int: PromptParticleEffect] = [:] private var running = false @@ -165,17 +202,17 @@ let targets = PromptSigilOverlayController.shared.promptParticleTargets let liveIDs = Set(targets.map(\.windowID)) let focusedID = focusedWindowID() + let stackAnchorID = frontmostWindowID(in: liveIDs) for target in targets { let effect = effects[target.windowID] ?? makeEffect(for: target.windowID) effects[target.windowID] = effect let windowFrame = appKitFrame(for: target.frame) - let requested = CGRect( - x: windowFrame.minX - Self.horizontalPad, - y: windowFrame.minY - Self.dropDepth, - width: windowFrame.width + Self.horizontalPad * 2, - height: Self.dropDepth + Self.topOverlap) + // Keep the render canvas fixed in global desktop coordinates. + // Moving only the emitter positions lets particles already in + // flight retain their world position while the terminal moves. + let requested = desktopFrame() if effect.panel.frame != requested { effect.panel.setFrame(requested, display: false) } @@ -188,13 +225,18 @@ x: windowFrame.minX - actual.minX, y: windowFrame.minY - actual.minY, width: windowFrame.width, height: windowFrame.height) - effect.view.configure(subject: subject, color: target.color) - effect.view.emissionRate = target.windowID == focusedID ? 14 : 6 + effect.view.configure(subject: subject, color: target.color, seed: target.seed) + effect.view.emissionRate = target.windowID == focusedID ? 24 : 11 effect.view.isEmitting = true - // Raise only inside the desktop-underlay level. Every ordinary - // terminal, preview, and app window remains above this panel, so - // a prompt's falling light can never paint across other content. - effect.panel.orderFrontRegardless() + // All particle fields share one place immediately above the + // frontmost tracked terminal. They therefore paint across the + // complete terminal wall, while unrelated windows already above + // that terminal remain above the particles too. + if let anchor = stackAnchorID { + effect.panel.order(.above, relativeTo: anchor) + } else { + effect.panel.orderOut(nil) + } } let staleIDs = effects.keys.filter { !liveIDs.contains($0) } @@ -211,13 +253,9 @@ backing: .buffered, defer: false) panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = false - // This is an UNDERLAY, unlike the floating Prompt Rock itself. Put it - // one level above the desktop wallpaper but below desktop icons and - // the entire normal-window stack. Ordering below one foreign Terminal - // window is insufficient: other lower terminals can still wind up - // beneath the panel and get painted over. - panel.level = NSWindow.Level( - Int(CGWindowLevelForKey(.desktopWindow)) + 1) + // Share the ordinary window layer so refreshNow can insert the field + // above the terminal wall without forcing it over unrelated apps. + panel.level = .normal panel.ignoresMouseEvents = true panel.hidesOnDeactivate = false panel.collectionBehavior = [.canJoinAllSpaces, .stationary, @@ -237,9 +275,29 @@ guard _FocusAXUIElementGetWindow(focused, &id) == .success, id != 0 else { return nil } return Int(id) } + /// CGWindowList is front-to-back. Pick the first tracked terminal so all + /// particle panels can be inserted above the complete terminal wall. + private func frontmostWindowID(in ids: Set) -> Int? { + guard !ids.isEmpty, + let infos = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] + else { return nil } + for info in infos { + guard let layer = info[kCGWindowLayer as String] as? Int, layer == 0, + let number = info[kCGWindowNumber as String] as? Int, + ids.contains(number) else { continue } + return number + } + return nil + } + private func appKitFrame(for cgFrame: CGRect) -> CGRect { let desktopTop = NSScreen.screens.map(\.frame.maxY).max() ?? 0 return CGRect(x: cgFrame.minX, y: desktopTop - cgFrame.maxY, width: cgFrame.width, height: cgFrame.height) + } + + private func desktopFrame() -> CGRect { + NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) } } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -11,13 +11,11 @@ // working / awaiting / complete, and a poke from a peer makes it blink and // rattle. It carries a pet name in bubble lettering, and pointing at it // reveals a slowly evolving account of the session. // -// Each rock is a borderless, click-through `.floating` window, so it rides -// above the whole normal-window stack and can't be buried by the wall of -// preview cards. That means occlusion is OUR job, in two places that must -// agree: `reposition` hides a rock whose terminal corner is covered, and -// `overlayAt` refuses the pointer to a rock that is hidden or covered at the -// cursor β€” otherwise a stone wakes up and pops its bubble through whatever -// window is sitting on top of it. +// Each rock is a borderless, click-through normal-level window ordered directly +// above its own terminal. AppKit then gives it the terminal's real place in the +// window stack: windows above that terminal also cover the rock. `overlayAt` +// still checks the stack at the pointer so a covered stone cannot answer hover +// or clicks during the short interval before AppKit/window snapshots settle. import AppKit import CoreGraphics @@ -281,11 +279,9 @@ /// the controller sets from the session's STATUS β€” so motion is the status /// channel. The layer spin is GPU-driven (render-server side, CPU stays idle), /// so the only recurring cost is the controller's light reposition tick. /// -/// Compositing: the window FLOATS above the normal-window stack. Rocks used -/// to ride the z-order just above their terminal (correctly occluded by -/// covering windows), but a wall full of preview cards kept burying them β€” -/// so now every session's stone is always visible and pointable, and the -/// whole raise/behind-detection dance is gone. +/// Compositing: the window lives at normal level and is inserted immediately +/// above its terminal. It therefore follows the terminal's z-order instead of +/// painting over unrelated foreground windows. final class PromptSigilOverlay { private static let fuseParticle: CGImage? = { let side = 8 @@ -389,10 +385,10 @@ window.isOpaque = false window.backgroundColor = .clear window.hasShadow = false window.ignoresMouseEvents = true - // Floating: rocks ride above the whole normal-window soup (preview - // cards, other apps), so every session's stone is always visible and - // clickable β€” no more burying under whatever the wall accumulates. - window.level = .floating + // Stay in the normal-window stack. `orderAbove` places this rock just + // above its terminal, leaving every window above that terminal above + // the rock as well. + window.level = .normal window.collectionBehavior = [.fullScreenAuxiliary] let heartbeatInitial = NSRect(x: -2000, y: -2000, width: 160, height: 40) @@ -1160,9 +1156,7 @@ } } /// Is the badge actually on screen right now? A hidden rock keeps its - /// `hitRect` (the frame doesn't move when it's ordered out), so the hover - /// hit-test has to consult this or the stone answers the pointer from - /// under whatever is covering it. + /// `hitRect`, so the hover hit-test still consults visibility. var isOnScreen: Bool { window.isVisible } /// Ease the badge toward its target by a frame-rate-independent step. @@ -1593,6 +1587,7 @@ struct PromptParticleTarget { let windowID: Int let frame: CGRect // CG/AX coordinates: top-left origin let color: NSColor + let seed: UInt64 // stable prox pet-name seed } static let shared = PromptSigilOverlayController() @@ -1610,10 +1605,12 @@ var promptWindowIDs: Set { Set(binding.values) } var promptParticleTargets: [PromptParticleTarget] { particleColors.compactMap { tty, color in guard let id = binding[tty], let b = lastBoundsByNum[id] else { return nil } + let proxName = overlays.values.first(where: { $0.tty == tty })?.name ?? tty return PromptParticleTarget( windowID: id, frame: CGRect(x: b.0, y: b.1, width: b.2, height: b.3), - color: color) + color: color, + seed: SigilRenderer.seed(for: proxName)) } } private var needsRebind = false @@ -1710,6 +1707,16 @@ scheduleTick(after: activeInterval) } } + /// The tiler can move several terminal windows in one AX sweep faster than + /// per-window notifications arrive. Refresh tty β†’ window ownership now so + /// rocks transfer with the completed layout instead of waiting for the + /// five-second safety bind. + func terminalsDidRetile() { + needsRebind = true + promote() + reposition() + } + /// Read an AXValue geometry attribute (point or size) off an element. private func axValue(_ el: AXUIElement, _ attr: String, _ type: AXValueType) -> Any? { var ref: CFTypeRef? @@ -2328,15 +2335,16 @@ } /// In-process snapshot of the on-screen window stack. `terminals` maps /// each Terminal/iTerm2 window's CGWindowID to its bounds {x,y,w,h}; - /// `stack` is EVERY normal-level window front-to-back β€” what the + /// `stack` is EVERY normal-level window front-to-back β€” what the pointer /// occlusion check walks to find the topmost window at a rock's spot. - /// (Badges float at a higher level and the bubble sits higher still, so - /// neither appears in the layer-0 stack.) No fork. + /// Rock windows belong to this layer too, but are mouse-transparent and + /// are ignored below by window number when appropriate. No fork. private func snapshotWindows() -> (terminals: [Int: (CGFloat, CGFloat, CGFloat, CGFloat)], stack: [(num: Int, rect: CGRect)]) { let pids = Set(NSWorkspace.shared.runningApplications .filter { Self.terminalBundleIds.contains($0.bundleIdentifier ?? "") } .map { $0.processIdentifier }) + let rockWindowNumbers = Set(overlays.values.map(\.windowNumber)) guard let infos = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { return ([:], []) } var terminals: [Int: (CGFloat, CGFloat, CGFloat, CGFloat)] = [:] diff --git a/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift new file mode 100644 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ResourceGraph.swift @@ -0,0 +1,290 @@ +import AppKit +import Darwin +import IOKit + +/// A Slab-owned replacement for the Stats menu extra. It reads like a stock +/// ticker: one legible metric/value plus its sparkline, rotating through all +/// five channels; hover/click exposes the complete snapshot. +final class ResourceGraph { + static let shared = ResourceGraph() + + private struct Sample { + var ram = 0.0, ssd = 0.0, gpu = 0.0, cpu = 0.0, net = 0.0 + var down = 0.0, up = 0.0 + var ramUsedGB = 0.0, ramTotalGB = 0.0 + var ssdUsedGB = 0.0, ssdFreeGB = 0.0, ssdTotalGB = 0.0 + var load = 0.0 + } + + private var item: NSStatusItem? + private var timer: Timer? + private var history: [Sample] = [] + private var previousCPU: (idle: UInt64, total: UInt64)? + private var previousNetwork: (down: UInt64, up: UInt64, at: TimeInterval)? + private var sample = Sample() + private var tickCount = 0 + + var enabled: Bool { FileManager.default.fileExists(atPath: Paths.resourceGraphFlag) } + + func syncEnabled() { + enabled ? start() : stop() + } + + func toggle() { + let fm = FileManager.default + if enabled { + try? fm.removeItem(atPath: Paths.resourceGraphFlag) + } else { + try? fm.createDirectory(atPath: (Paths.resourceGraphFlag as NSString).deletingLastPathComponent, + withIntermediateDirectories: true) + fm.createFile(atPath: Paths.resourceGraphFlag, contents: nil) + } + syncEnabled() + } + + func stop() { + timer?.invalidate() + timer = nil + if let item { NSStatusBar.system.removeStatusItem(item) } + item = nil + history.removeAll() + previousCPU = nil + previousNetwork = nil + } + + private func start() { + guard item == nil else { return } + let status = NSStatusBar.system.statusItem(withLength: 92) + status.button?.imagePosition = .imageOnly + let menu = NSMenu() + menu.autoenablesItems = false + status.menu = menu + item = status + tick() + let t = Timer(timeInterval: 2, repeats: true) { [weak self] _ in self?.tick() } + timer = t + RunLoop.main.add(t, forMode: .common) + } + + private func tick() { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self else { return } + let fresh = self.readSample() + DispatchQueue.main.async { [weak self] in + guard let self, self.item != nil else { return } + self.sample = fresh + self.history.append(fresh) + if self.history.count > 36 { self.history.removeFirst(self.history.count - 36) } + self.tickCount += 1 + self.redraw() + } + } + } + + private func redraw() { + guard let button = item?.button else { return } + button.image = render() + button.contentTintColor = nil + let text = String(format: "RAM %.0f%% Β· SSD %.0f%% Β· GPU %.0f%% Β· CPU %.0f%% Β· net ↓%.1f ↑%.1f MB/s", + sample.ram * 100, sample.ssd * 100, sample.gpu * 100, + sample.cpu * 100, sample.down, sample.up) + button.toolTip = hoverDetails() + let menu = item?.menu + menu?.removeAllItems() + let row = NSMenuItem(title: text, action: nil, keyEquivalent: "") + row.isEnabled = false + menu?.addItem(row) + let legend = NSMenuItem(title: "RAM SSD GPU CPU NET", action: nil, keyEquivalent: "") + legend.isEnabled = false + menu?.addItem(legend) + } + + private func render() -> NSImage { + let size = NSSize(width: 90, height: 18) + let image = NSImage(size: size) + image.lockFocus() + NSColor.clear.setFill() + NSRect(origin: .zero, size: size).fill() + let colors: [NSColor] = [.systemPink, .systemOrange, .systemPurple, .systemGreen, + NSColor(calibratedRed: 0.1, green: 0.75, blue: 0.9, alpha: 1)] + let values: [(Sample) -> Double] = [{ $0.ram }, { $0.ssd }, { $0.gpu }, { $0.cpu }, { $0.net }] + let names = ["RAM", "SSD", "GPU", "CPU", "NET"] + // Hold each symbol for six seconds (three samples), long enough to + // read without making the menubar feel static. + let selected = (tickCount / 3) % names.count + let color = colors[selected] + let frame = NSBezierPath(roundedRect: NSRect(x: 0.5, y: 0.5, width: 89, height: 17), + xRadius: 3, yRadius: 3) + NSColor.labelColor.withAlphaComponent(0.28).setStroke() + frame.lineWidth = 1 + frame.stroke() + + let current = values[selected](sample) + let displayValue: String + if selected == 4 { + displayValue = rate(sample.down + sample.up).replacingOccurrences(of: "/s", with: "") + } else { + displayValue = String(format: "%.0f%%", current * 100) + } + let label = "\(names[selected]) \(displayValue)" + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 9, weight: .semibold), + .foregroundColor: NSColor.labelColor, + ] + label.draw(in: NSRect(x: 4, y: 4, width: 48, height: 11), withAttributes: attrs) + + let graph = NSRect(x: 53, y: 3, width: 32, height: 12) + let points = history.suffix(32) + if points.count > 1 { + let line = NSBezierPath() + for (offset, point) in points.enumerated() { + let value = max(0, min(1, values[selected](point))) + let p = NSPoint(x: graph.minX + CGFloat(offset), + y: graph.minY + CGFloat(value) * graph.height) + offset == 0 ? line.move(to: p) : line.line(to: p) + } + color.setStroke() + line.lineWidth = 1.5 + line.stroke() + } + // Five quote-board lamps make the aggregate nature visible even while + // a single channel gets the readable ticker slot. + for i in 0..<5 { + (i == selected ? colors[i] : colors[i].withAlphaComponent(0.25)).setFill() + NSRect(x: 54 + CGFloat(i * 6), y: 1, width: 4, height: 1).fill() + } + image.unlockFocus() + image.isTemplate = false + return image + } + + private func readSample() -> Sample { + var s = Sample() + let memory = memoryUse() + s.ram = memory.fraction + s.ramUsedGB = memory.usedGB + s.ramTotalGB = memory.totalGB + let disk = diskUse() + s.ssd = disk.fraction + s.ssdUsedGB = disk.usedGB + s.ssdFreeGB = disk.freeGB + s.ssdTotalGB = disk.totalGB + s.gpu = gpuUse() + s.cpu = cpuUse() + var loads = [Double](repeating: 0, count: 3) + if getloadavg(&loads, 3) > 0 { s.load = loads[0] } + let network = networkUse() + s.down = network.down + s.up = network.up + // A log scale keeps ordinary traffic visible while tolerating bursts; + // 100 MB/s reaches the top of the row. + s.net = min(1, log10(1 + network.down + network.up) / log10(101)) + return s + } + + private func hoverDetails() -> String { + let freeRAM = max(0, sample.ramTotalGB - sample.ramUsedGB) + return [ + String(format: "RAM %.1f / %.1f GB used (%.1f GB free) Β· %.0f%%", + sample.ramUsedGB, sample.ramTotalGB, freeRAM, sample.ram * 100), + String(format: "SSD %.0f / %.0f GB used (%.0f GB free) Β· %.0f%%", + sample.ssdUsedGB, sample.ssdTotalGB, sample.ssdFreeGB, sample.ssd * 100), + String(format: "GPU %.0f%% utilization", sample.gpu * 100), + String(format: "CPU %.0f%% utilization Β· load %.2f Β· %d cores", + sample.cpu * 100, sample.load, ProcessInfo.processInfo.processorCount), + "NET ↓ \(rate(sample.down)) ↑ \(rate(sample.up))", + "HISTORY 72 seconds Β· sampled every 2 seconds", + ].joined(separator: "\n") + } + + private func rate(_ mbPerSecond: Double) -> String { + if mbPerSecond >= 1 { return String(format: "%.1f MB/s", mbPerSecond) } + return String(format: "%.0f KB/s", mbPerSecond * 1024) + } + + private func memoryUse() -> (fraction: Double, usedGB: Double, totalGB: Double) { + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &stats) { p in + p.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return (0, 0, 0) } + let pageSize = Double(vm_kernel_page_size) + let usedPages = Double(stats.active_count + stats.inactive_count + stats.wire_count + stats.compressor_page_count) + let totalPages = usedPages + Double(stats.free_count + stats.speculative_count) + let gb = 1_073_741_824.0 + return (totalPages > 0 ? usedPages / totalPages : 0, + usedPages * pageSize / gb, totalPages * pageSize / gb) + } + + private func diskUse() -> (fraction: Double, usedGB: Double, freeGB: Double, totalGB: Double) { + var fs = statfs() + guard statfs("/", &fs) == 0, fs.f_blocks > 0 else { return (0, 0, 0, 0) } + let total = Double(fs.f_blocks) * Double(fs.f_bsize) + let free = Double(fs.f_bavail) * Double(fs.f_bsize) + let used = total - free + let gb = 1_073_741_824.0 + return (used / total, used / gb, free / gb, total / gb) + } + + private func cpuUse() -> Double { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t(MemoryLayout.size / + MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { p in + p.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return 0 } + let idle = UInt64(info.cpu_ticks.2) + let total = UInt64(info.cpu_ticks.0) + UInt64(info.cpu_ticks.1) + idle + UInt64(info.cpu_ticks.3) + defer { previousCPU = (idle, total) } + guard let old = previousCPU, total > old.total else { return 0 } + return 1 - Double(idle - old.idle) / Double(total - old.total) + } + + private func networkUse() -> (down: Double, up: Double) { + var addresses: UnsafeMutablePointer? + guard getifaddrs(&addresses) == 0, let first = addresses else { return (0, 0) } + defer { freeifaddrs(addresses) } + var down: UInt64 = 0, up: UInt64 = 0 + var p: UnsafeMutablePointer? = first + while let current = p { + let flags = Int32(current.pointee.ifa_flags) + if flags & IFF_UP != 0, flags & IFF_LOOPBACK == 0, + let data = current.pointee.ifa_data?.assumingMemoryBound(to: if_data.self) { + down += UInt64(data.pointee.ifi_ibytes) + up += UInt64(data.pointee.ifi_obytes) + } + p = current.pointee.ifa_next + } + let now = Date.timeIntervalSinceReferenceDate + defer { previousNetwork = (down, up, now) } + guard let old = previousNetwork, now > old.at else { return (0, 0) } + let elapsed = now - old.at + let downDelta = down >= old.down ? down - old.down : 0 + let upDelta = up >= old.up ? up - old.up : 0 + return (Double(downDelta) / elapsed / 1_048_576, + Double(upDelta) / elapsed / 1_048_576) + } + + private func gpuUse() -> Double { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("IOAccelerator"), &iterator) == KERN_SUCCESS else { return 0 } + defer { IOObjectRelease(iterator) } + var best = 0.0 + while true { + let service = IOIteratorNext(iterator) + if service == 0 { break } + defer { IOObjectRelease(service) } + guard let raw = IORegistryEntryCreateCFProperty(service, "PerformanceStatistics" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? [String: Any] else { continue } + for key in ["Device Utilization %", "GPU Core Utilization", "GPU Activity(%)"] { + if let n = raw[key] as? NSNumber { best = max(best, n.doubleValue / 100) } + } + } + return min(1, best) + } +} diff --git a/toolchain/macos/fleet/README.md b/toolchain/macos/fleet/README.md --- a/toolchain/macos/fleet/README.md +++ b/toolchain/macos/fleet/README.md @@ -3,24 +3,19 @@ Small idempotent scripts to keep @jeffrey's Macs (neo, blueberry, chicken, panda) consistently configured. Run from any host that can `ssh` the targets. -## Stats (menu-bar system monitor) +## Resource graph (Slab menu-bar system monitor) -`stats-shared.plist` is the canonical [exelban/Stats](https://github.com/exelban/stats) -config β€” it mirrors neo's menu bar: **CPU / RAM / GPU / Disk** as `mini` widgets + -**Network** as `speed`; Sensors and Battery off. Machine-specific keys (`remote_id`, -status-item/window positions) are excluded so each Mac lays out naturally. +Stats is deprecated for the fleet. Slab now owns a framed ticker that rotates +through **RAM / SSD / GPU / CPU / Network** with a live value and sparkline, +sampled directly without another app. +Enable it from **Slab β†’ Resource graph**; the checkbox persists per machine. ```bash -bash stats-sync.sh all # neo chicken panda -bash stats-sync.sh chicken panda # specific hosts +bash stats-sync.sh all # exits with a migration reminder ``` -Each run imports the config, registers Stats as a login item (launches at boot), -and opens it. Stats' own auto-updater keeps versions at parity once it's running. - -Login-at-startup uses a classic LaunchServices login item, because Stats 3.x's -"Start at login" toggle is SMAppService-backed and not reliably settable from the -CLI. Both mechanisms launch the same single-instance app, so there's no conflict. +The historical plist and sync implementation remain in the tree only as a +migration reference. New provisioning does not install or launch Stats. ## Cursor color @@ -156,12 +151,12 @@ ## Blueberry bootstrap Blueberry does **not** trust the fleet key yet and isn't SSH-reachable, so it can't be pushed to. `blueberry-join.sh` is a self-contained one-shot that adds the fleet -keys to its `authorized_keys` and configures Stats. It's staged on neo, so run this +keys to its `authorized_keys` and configures its cursor identity. It's staged on neo, so run this **on blueberry** (blueberry already holds a key to neo): ```bash ssh neo 'cat ~/blueberry-join.sh' > /tmp/bj.sh && bash /tmp/bj.sh ``` -After that, blueberry joins the mesh and `ssh-mesh.sh` / `stats-sync.sh` work on it -like any other host. +After that, blueberry joins the mesh and `ssh-mesh.sh` works on it like any +other host. Enable the Slab resource graph locally from its persisted checkbox. diff --git a/toolchain/macos/fleet/blueberry-join.sh b/toolchain/macos/fleet/blueberry-join.sh --- a/toolchain/macos/fleet/blueberry-join.sh +++ b/toolchain/macos/fleet/blueberry-join.sh @@ -1,7 +1,7 @@ #!/bin/bash -# One-shot: add fleet SSH keys + configure Stats to match the neo/chicken/panda fleet. +# One-shot: add fleet SSH keys and configure Blueberry's cursor identity. set -e -echo "== 1/5 fleet SSH keys ==" +echo "== 1/2 fleet SSH keys ==" mkdir -p ~/.ssh; touch ~/.ssh/authorized_keys; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys cat > /tmp/fleet-keys.pub <<'KEYS' ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM5Dbs/qJ3ut0TTkK37F260rP6wjOaTNfEbweDTjgmHv jas@aesthetic -> aesthetics-macbook-pro (via tailscale) @@ -19,37 +19,11 @@ done < /tmp/fleet-keys.pub rm -f /tmp/fleet-keys.pub echo "authorized_keys: $before -> $(grep -c . ~/.ssh/authorized_keys) keys" -echo "== 2/5 Stats config ==" -if [ ! -d /Applications/Stats.app ]; then - echo "Stats not installed; installing via brew..."; brew install --cask stats || { echo "install Stats manually from https://github.com/exelban/stats"; exit 1; } -fi -pkill -x Stats 2>/dev/null || true; sleep 1 -echo "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI+CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KCTxrZXk+QmF0dGVyeV9zdGF0ZTwva2V5PgoJPGZhbHNlLz4KCTxrZXk+Q1BVX2JhckNoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4zPC9pbnRlZ2VyPgoJPGtleT5DUFVfbGFiZWxfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjE8L2ludGVnZXI+Cgk8a2V5PkNQVV9saW5lQ2hhcnRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjI8L2ludGVnZXI+Cgk8a2V5PkNQVV9taW5pX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4wPC9pbnRlZ2VyPgoJPGtleT5DUFVfcGllQ2hhcnRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjQ8L2ludGVnZXI+Cgk8a2V5PkNQVV9zdGF0ZTwva2V5PgoJPHRydWUvPgoJPGtleT5DUFVfdGFjaG9tZXRlcl9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NTwvaW50ZWdlcj4KCTxrZXk+Q1BVX3dpZGdldDwva2V5PgoJPHN0cmluZz5taW5pPC9zdHJpbmc+Cgk8a2V5PkRpc2tfYmFyQ2hhcnRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjM8L2ludGVnZXI+Cgk8a2V5PkRpc2tfbGFiZWxfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjI8L2ludGVnZXI+Cgk8a2V5PkRpc2tfbWVtb3J5X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj41PC9pbnRlZ2VyPgoJPGtleT5EaXNrX21pbmlfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjA8L2ludGVnZXI+Cgk8a2V5PkRpc2tfbmV0d29ya0NoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj42PC9pbnRlZ2VyPgoJPGtleT5EaXNrX3BpZUNoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj40PC9pbnRlZ2VyPgoJPGtleT5EaXNrX3NwZWVkX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgoJPGtleT5EaXNrX3N0YXRlPC9rZXk+Cgk8dHJ1ZS8+Cgk8a2V5PkRpc2tfdGV4dF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NzwvaW50ZWdlcj4KCTxrZXk+RGlza193aWRnZXQ8L2tleT4KCTxzdHJpbmc+bWluaTwvc3RyaW5nPgoJPGtleT5HUFVfYmFyQ2hhcnRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjM8L2ludGVnZXI+Cgk8a2V5PkdQVV9sYWJlbF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+MTwvaW50ZWdlcj4KCTxrZXk+R1BVX2xpbmVDaGFydF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+MjwvaW50ZWdlcj4KCTxrZXk+R1BVX21pbmlfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjA8L2ludGVnZXI+Cgk8a2V5PkdQVV9zdGF0ZTwva2V5PgoJPHRydWUvPgoJPGtleT5HUFVfdGFjaG9tZXRlcl9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NDwvaW50ZWdlcj4KCTxrZXk+R1BVX3dpZGdldDwva2V5PgoJPHN0cmluZz5taW5pPC9zdHJpbmc+Cgk8a2V5Pk5ldHdvcmtfYmFzZTwva2V5PgoJPHN0cmluZz5ieXRlPC9zdHJpbmc+Cgk8a2V5Pk5ldHdvcmtfZG93bmxvYWRDb2xvcjwva2V5PgoJPHN0cmluZz5zeXN0ZW08L3N0cmluZz4KCTxrZXk+TmV0d29ya19sYWJlbF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NDwvaW50ZWdlcj4KCTxrZXk+TmV0d29ya19uZXR3b3JrQ2hhcnRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjE8L2ludGVnZXI+Cgk8a2V5Pk5ldHdvcmtfcHJvY2Vzc2VzPC9rZXk+Cgk8aW50ZWdlcj44PC9pbnRlZ2VyPgoJPGtleT5OZXR3b3JrX3NwZWVkX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4wPC9pbnRlZ2VyPgoJPGtleT5OZXR3b3JrX3N0YXRlPC9rZXk+Cgk8dHJ1ZS8+Cgk8a2V5Pk5ldHdvcmtfc3RhdGVfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjI8L2ludGVnZXI+Cgk8a2V5Pk5ldHdvcmtfdGV4dF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+MzwvaW50ZWdlcj4KCTxrZXk+TmV0d29ya191cGxvYWRDb2xvcjwva2V5PgoJPHN0cmluZz5tb25vY2hyb21lPC9zdHJpbmc+Cgk8a2V5Pk5ldHdvcmtfd2lkZ2V0PC9rZXk+Cgk8c3RyaW5nPnNwZWVkPC9zdHJpbmc+Cgk8a2V5PlJBTV9iYXJDaGFydF9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NDwvaW50ZWdlcj4KCTxrZXk+UkFNX2xhYmVsX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4yPC9pbnRlZ2VyPgoJPGtleT5SQU1fbGluZUNoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4zPC9pbnRlZ2VyPgoJPGtleT5SQU1fbWVtb3J5X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4xPC9pbnRlZ2VyPgoJPGtleT5SQU1fbWluaV9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+MDwvaW50ZWdlcj4KCTxrZXk+UkFNX3BpZUNoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj41PC9pbnRlZ2VyPgoJPGtleT5SQU1fc3RhdGU8L2tleT4KCTx0cnVlLz4KCTxrZXk+UkFNX3N0YXRlX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj44PC9pbnRlZ2VyPgoJPGtleT5SQU1fdGFjaG9tZXRlcl9wb3NpdGlvbjwva2V5PgoJPGludGVnZXI+NjwvaW50ZWdlcj4KCTxrZXk+UkFNX3RleHRfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjc8L2ludGVnZXI+Cgk8a2V5PlJBTV93aWRnZXQ8L2tleT4KCTxzdHJpbmc+bWluaTwvc3RyaW5nPgoJPGtleT5TZW5zb3JzX2JhckNoYXJ0X3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4yPC9pbnRlZ2VyPgoJPGtleT5TZW5zb3JzX2xhYmVsX3Bvc2l0aW9uPC9rZXk+Cgk8aW50ZWdlcj4zPC9pbnRlZ2VyPgoJPGtleT5TZW5zb3JzX21pbmlfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjA8L2ludGVnZXI+Cgk8a2V5PlNlbnNvcnNfc3RhY2tfcG9zaXRpb248L2tleT4KCTxpbnRlZ2VyPjE8L2ludGVnZXI+Cgk8a2V5PlNlbnNvcnNfc3RhdGU8L2tleT4KCTxmYWxzZS8+Cgk8a2V5PlNlbnNvcnNfd2lkZ2V0PC9rZXk+Cgk8c3RyaW5nPm1pbmk8L3N0cmluZz4KPC9kaWN0Pgo8L3BsaXN0Pgo=" | base64 -d > /tmp/stats-shared.plist -python3 - <<'PY' -import plistlib, subprocess -p = plistlib.load(open("/tmp/stats-shared.plist","rb")) -for k,v in p.items(): - if isinstance(v,bool): t,vv="-bool",("true" if v else "false") - elif isinstance(v,int): t,vv="-int",str(v) - else: t,vv="-string",str(v) - subprocess.run(["defaults","write","eu.exelban.Stats",k,t,vv],check=True) -print("imported",len(p),"Stats keys") -PY - -echo "== 3/5 login item ==" -osascript -e 'tell application "System Events" to delete (every login item whose name is "Stats")' >/dev/null 2>&1 || true -osascript -e 'tell application "System Events" to make login item at end with properties {path:"/Applications/Stats.app", hidden:true}' >/dev/null 2>&1 || true -echo "login items: $(osascript -e 'tell application "System Events" to get the name of every login item')" - -echo "== 4/5 launch ==" -open -a Stats; sleep 2 -pgrep -x Stats >/dev/null && echo "Stats RUNNING βœ…" || echo "Stats did not launch" - -echo "== 5/5 cursor color (blue) ==" +echo "== 2/2 cursor color (blue) ==" # blueberry's signature: a blue pointer. Shows on next login / lock-unlock (βŒƒβŒ˜Q), # since SIP blocks hot-reloading universalaccessd from the CLI. defaults write com.apple.universalaccess cursorFill -dict alpha 1 red 0 green 0 blue 1 defaults write com.apple.universalaccess cursorOutline -dict alpha 1 red 1 green 1 blue 1 echo "cursorFill set to blue (applies on next login / lock-unlock)" -echo "DONE β€” blueberry has joined the SSH mesh, Stats matches the fleet, cursor is blue." +echo "DONE β€” blueberry has joined the SSH mesh and its cursor is blue." diff --git a/toolchain/macos/fleet/stats-sync.sh b/toolchain/macos/fleet/stats-sync.sh --- a/toolchain/macos/fleet/stats-sync.sh +++ b/toolchain/macos/fleet/stats-sync.sh @@ -1,4 +1,7 @@ #!/bin/bash +echo "stats-sync.sh is deprecated; enable Slab > Resource graph on each fleet Mac instead." >&2 +exit 2 +# Historical implementation retained below for reference. # stats-sync.sh β€” push the canonical Stats (exelban) menu-bar config to fleet Macs, # register Stats as a login item, and launch it. Idempotent. # diff --git a/toolchain/mcp/iris-mcp.mjs b/toolchain/mcp/iris-mcp.mjs new file mode 100644 --- /dev/null +++ b/toolchain/mcp/iris-mcp.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Keep the Iris MCP available when panda is asleep or off the network. + +import { spawn, spawnSync } from "node:child_process"; +import * as readline from "node:readline"; +import { pathToFileURL } from "node:url"; + +const ssh = process.env.IRIS_MCP_SSH || "ssh"; +const host = process.env.IRIS_MCP_HOST || "panda"; +const connectTimeout = process.env.IRIS_MCP_CONNECT_TIMEOUT || "3"; +const remoteCommand = process.env.IRIS_MCP_REMOTE_COMMAND + || "/opt/homebrew/bin/node /Users/fusermacminipanda/Developer/fuser/tools/iris/iris-mcp.mjs"; +const remoteProbe = process.env.IRIS_MCP_REMOTE_PROBE + || "test -x /opt/homebrew/bin/node && test -f /Users/fusermacminipanda/Developer/fuser/tools/iris/iris-mcp.mjs"; + +export function offlineResponse(message) { + if (message.id === undefined || message.id === null) return null; + const result = { + initialize: { + protocolVersion: message.params?.protocolVersion || "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "iris-offline", version: "1.0.0" }, + }, + "tools/list": { tools: [] }, + "resources/list": { resources: [] }, + "prompts/list": { prompts: [] }, + ping: {}, + }[message.method]; + if (result) return { jsonrpc: "2.0", id: message.id, result }; + return { + jsonrpc: "2.0", + id: message.id, + error: { code: -32001, message: "Iris is offline or unreachable" }, + }; +} + +function serveOffline(reason) { + console.error(`iris-mcp: Iris unavailable (${reason}); serving an empty MCP`); + const lines = readline.createInterface({ input: process.stdin, terminal: false }); + lines.on("line", (line) => { + if (!line.trim()) return; + try { + const response = offlineResponse(JSON.parse(line)); + if (response) process.stdout.write(`${JSON.stringify(response)}\n`); + } catch (error) { + process.stderr.write(`iris-mcp: ignored invalid JSON: ${error.message}\n`); + } + }); +} + +export function main() { + const common = ["-o", "BatchMode=yes", "-o", `ConnectTimeout=${connectTimeout}`, host]; + const probe = spawnSync(ssh, [...common, remoteProbe], { encoding: "utf8", timeout: (Number(connectTimeout) + 1) * 1000 }); + if (probe.status !== 0) { + serveOffline(probe.error?.message || probe.stderr.trim() || `ssh exited ${probe.status}`); + return; + } + + const child = spawn(ssh, [...common, remoteCommand], { stdio: ["pipe", "pipe", "inherit"] }); + process.stdin.pipe(child.stdin); + child.stdout.pipe(process.stdout); + child.on("error", (error) => { + console.error(`iris-mcp: connection failed after probe: ${error.message}`); + process.exitCode = 0; + }); + child.on("exit", (code) => { process.exitCode = code || 0; }); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/toolchain/mcp/iris-mcp.test.mjs b/toolchain/mcp/iris-mcp.test.mjs new file mode 100644 --- /dev/null +++ b/toolchain/mcp/iris-mcp.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { offlineResponse } from "./iris-mcp.mjs"; + +test("offline server advertises no Iris tools", () => { + assert.deepEqual(offlineResponse({ jsonrpc: "2.0", id: 1, method: "tools/list" }), { + jsonrpc: "2.0", id: 1, result: { tools: [] }, + }); +}); + +test("unreachable Iris still completes the MCP handshake", async () => { + const child = spawn(process.execPath, [fileURLToPath(new URL("./iris-mcp.mjs", import.meta.url))], { + env: { ...process.env, IRIS_MCP_SSH: "/usr/bin/false" }, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method: "initialize", params: { protocolVersion: "test-version" } })}\n`); + const line = await new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout }); + lines.once("line", resolve); + child.once("error", reject); + child.once("exit", (code) => reject(new Error(`offline MCP exited before replying (${code})`))); + }); + assert.deepEqual(JSON.parse(line), { + jsonrpc: "2.0", id: 7, + result: { + protocolVersion: "test-version", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "iris-offline", version: "1.0.0" }, + }, + }); + child.kill(); +}); -- tangled.sh