diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 0ee55f5374..2f1fb9a2aa 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -163,12 +163,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // watcher — never touches ScreenCaptureKit until a frame is actually // requested, so no Screen Recording prompt at launch (lazy grant). FrameCapture.shared.start() + + // Advertised ledger: serve this machine's handles over the tailnet and + // cache peers' ledgers, so `host:name` references resolve O(1) without + // an SSH crawl. Overlay stays local — this is a data channel only. + LedgerStore.shared.start() } func applicationWillTerminate(_ notification: Notification) { tileHotkey?.unregister() appearanceHotkey?.unregister() passphraseServer.stop() + LedgerStore.shared.stop() NSWorkspace.shared.notificationCenter.removeObserver(self) } @@ -200,6 +206,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // serialized by the `gathering` guard) so decor + menu read one // consistent mark. snapshot.claudeSessions = TitleEmoji.assign(snapshot.claudeSessions) + // Publish this machine's ledger + refresh the peer cache (throttled + // inside; peer GETs are async URLSession — never block this queue). + LedgerStore.shared.tick(sessions: snapshot.claudeSessions, + peers: snapshot.tailnetPeers) // RENDERING overlay — a session whose turn is done but whose // launched render (a ~/.ac-pop-renders heartbeat tagged with its // sessionId) is still running shows pink `rendering` instead of diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift new file mode 100644 index 0000000000..efdf75d2ae --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -0,0 +1,424 @@ +// Ledger.swift — the fleet's advertised handle ledger. +// +// Each slab machine advertises a compact list of its live handles (Claude +// sessions + headless agents) as `host:name` — e.g. neo:geb, blueberry:flock, +// panda:iris. Peers cache each other's ledgers over the tailnet, so an agent +// resolving a `host:name` reference does an O(1) local lookup instead of an +// SSH+find crawl. +// +// Three moving parts, all here: +// • publishLocal — turns this machine's sessions (+ an advertise drop-dir for +// non-tty agents) into ~/.config/slab/ledger/local.json, kept with a small +// history log for provenance. +// • LedgerHTTPServer — serves that JSON over the tailnet, bound to THIS +// machine's tailscale IP only (tailnet is the auth perimeter, matching +// slab/flock's posture — no bearer token, no public bind). +// • fetchPeers — pulls each online peer's ledger onto disk (peers/.json) +// off the main thread, failure-tolerant, so resolves stay local + instant. +// +// The overlay stays local-only: rocks are never rendered for remote machines. +// This is a data channel, not a display one. +import Foundation + +// One advertised handle. `name` is the sigil pet-name (deterministic from the +// prompt, so it matches the rock on that machine's own overlay); `seed` carries +// the same identity as hex so a reference can be re-rendered anywhere. +struct LedgerEntry: Codable, Equatable { + var id: String + var host: String + var name: String + var subject: String + var status: String // working | awaiting | complete | rendering | blank | interrupted | agent-defined + var kind: String // "session" | "agent" + var seed: String // hex of the sigil seed + var cwd: String + var updated: Double // ms since epoch +} + +struct Ledger: Codable { + var host: String + var ip: String + var updatedAt: Double + var entries: [LedgerEntry] +} + +final class LedgerStore { + static let shared = LedgerStore() + + static let port: UInt16 = 5252 + static let peerFetchInterval: TimeInterval = 10 // don't hammer the tailnet every 2s tick + + private let queue = DispatchQueue(label: "slab.ledger") + private var server: LedgerHTTPServer? + private var lastPeerFetch = Date.distantPast + private var lastPublishedFingerprint = "" + private var selfHost = "" + private var selfIP = "" + + /// Transient "someone is reading this handle right now" marks, keyed by the + /// session id. Set when a poke beacon lands; the rock overlay reads these + /// each frame to blink + spin faster, and they decay after `observeWindow`. + /// Renewed on repeat pokes so sustained attention keeps the rock lively. + private var observed: [String: (by: String, until: Date)] = [:] + static let observeWindow: TimeInterval = 4 + /// Posted (main queue) with userInfo["id"] the moment a poke lands, so the + /// overlay loop wakes from idle and shows the reaction without waiting for + /// the next lazy tick. + static let observedNote = Notification.Name("slab.ledger.observed") + + // ── on-disk layout (kept: survives restarts) ───────────────────────── + static var dir: String { "\(Paths.home)/.config/slab/ledger" } + static var localFile: String { "\(dir)/local.json" } + static var peersDir: String { "\(dir)/peers" } + static var advertiseDir: String { "\(dir)/advertise" } // non-tty agents drop JSON here + static var historyLog: String { "\(dir)/history.jsonl" } + + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + return e + }() + + // ── lifecycle ──────────────────────────────────────────────────────── + /// Ensure the ledger dirs exist and start serving on the tailscale IP. + /// Safe to call once at launch; the server rebinds if the IP appears later. + func start() { + let fm = FileManager.default + for d in [Self.dir, Self.peersDir, Self.advertiseDir] { + try? fm.createDirectory(atPath: d, withIntermediateDirectories: true) + } + queue.async { [weak self] in self?.ensureServer() } + } + + func stop() { queue.async { [weak self] in self?.server?.stop(); self?.server = nil } } + + /// Called each refresh tick (off-main). Publishes this machine's ledger and, + /// on its own slower cadence, refreshes the peer cache. Never blocks the UI. + func tick(sessions: [ClaudeSession], peers: [TailnetPeer]) { + queue.async { [weak self] in + guard let self else { return } + self.ensureServer() + self.publishLocal(sessions: sessions) + if Date().timeIntervalSince(self.lastPeerFetch) >= Self.peerFetchInterval { + self.lastPeerFetch = Date() + self.fetchPeers(peers.filter { $0.online && !$0.ip.isEmpty }) + } + } + } + + // ── serving ────────────────────────────────────────────────────────── + private func ensureServer() { + let id = LedgerStore.selfIdentity() + selfHost = id.host + selfIP = id.ip + guard !selfIP.isEmpty else { return } // no tailnet yet — try again next tick + if let s = server, s.boundIP == selfIP { return } + server?.stop() + let s = try? LedgerHTTPServer(ip: selfIP, port: Self.port) + s?.onPoke = { [weak self] body in self?.receivePoke(body) } + server = s + } + + // ── observed (poke) state ──────────────────────────────────────────── + /// A peer resolved one of our handles. Mark the matching session observed + /// (renewing the decay window), log it as kept provenance, and wake the + /// overlay so the rock reacts immediately. `id` is preferred; `name` is the + /// fallback when only the pet-name was referenced. + private func receivePoke(_ body: [String: Any]) { + let by = (body["by"] as? String) ?? "someone" + let pokeId = (body["id"] as? String) ?? "" + let pokeName = (body["name"] as? String) ?? "" + let sid = sessionId(forId: pokeId, name: pokeName) + guard !sid.isEmpty else { return } + queue.async { [weak self] in + guard let self else { return } + self.observed[sid] = (by, Date().addingTimeInterval(Self.observeWindow)) + } + appendReference(sid: sid, name: pokeName, by: by) + DispatchQueue.main.async { + NotificationCenter.default.post(name: Self.observedNote, + object: nil, userInfo: ["id": sid]) + } + } + + /// Live observed record for a session, or nil once the window has decayed. + /// Thread-safe; the overlay controller calls this each frame. + func observation(for sessionId: String) -> (by: String, remaining: TimeInterval)? { + queue.sync { + guard let o = observed[sessionId] else { return nil } + let remaining = o.until.timeIntervalSinceNow + if remaining <= 0 { observed.removeValue(forKey: sessionId); return nil } + return (o.by, remaining) + } + } + + private func sessionId(forId id: String, name: String) -> String { + let entries = (try? JSONDecoder().decode( + Ledger.self, from: Data(contentsOf: URL(fileURLWithPath: Self.localFile))))?.entries ?? [] + if !id.isEmpty, entries.contains(where: { $0.id == id }) { return id } + if !name.isEmpty, let e = entries.first(where: { $0.name == name }) { return e.id } + return id + } + + private func appendReference(sid: String, name: String, by: String) { + let line = ["ts": Date().timeIntervalSince1970 * 1000, "host": selfHost, + "event": "referenced", "id": sid, "name": name, "by": by] as [String: Any] + guard let data = try? JSONSerialization.data(withJSONObject: line), + let text = String(data: data, encoding: .utf8) else { return } + let path = Self.historyLog + if let fh = FileHandle(forWritingAtPath: path) { + fh.seekToEndOfFile(); fh.write(Data((text + "\n").utf8)); try? fh.close() + } else { + try? (text + "\n").write(toFile: path, atomically: true, encoding: .utf8) + } + } + + // ── local publish (sessions + advertised agents) ───────────────────── + private func publishLocal(sessions: [ClaudeSession]) { + var entries: [LedgerEntry] = sessions.map { s in + let seed = SigilRenderer.seed(for: s.sessionId + "\u{1}" + s.subject) + return LedgerEntry( + id: s.sessionId, + host: selfHost, + name: SigilRenderer.name(seed: seed), + subject: s.titleString, + status: statusName(s.state), + kind: "session", + seed: String(format: "%016llx", seed), + cwd: s.cwd, + updated: s.updated.timeIntervalSince1970 * 1000) + } + entries.append(contentsOf: advertisedAgents()) + + let ledger = Ledger(host: selfHost, ip: selfIP, + updatedAt: Date().timeIntervalSince1970 * 1000, + entries: entries) + guard let data = try? encoder.encode(ledger) else { return } + try? data.write(to: URL(fileURLWithPath: Self.localFile), options: [.atomic]) + recordHistory(entries) + } + + /// Non-tty agents advertise by dropping a small JSON file in advertiseDir: + /// { "id": "iris", "name": "iris", "subject": "flock weaver", "status": "working" } + /// Stale drops (writer says a pid that's gone, or mtime > 5 min) are swept so + /// a crashed agent leaves no ghost handle. + private func advertisedAgents() -> [LedgerEntry] { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: Self.advertiseDir) else { return [] } + let now = Date() + var out: [LedgerEntry] = [] + for name in names where name.hasSuffix(".json") { + let path = "\(Self.advertiseDir)/\(name)" + guard let data = fm.contents(atPath: path), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + let pid = (obj["pid"] as? Int) ?? 0 + let dead = pid > 0 && kill(pid_t(pid), 0) != 0 && errno == ESRCH + let mtime = (try? fm.attributesOfItem(atPath: path)[.modificationDate] as? Date) ?? now + if dead || now.timeIntervalSince(mtime) > 300 { + try? fm.removeItem(atPath: path) + continue + } + let id = (obj["id"] as? String) ?? (name as NSString).deletingPathExtension + let handle = (obj["name"] as? String) ?? id + let subject = (obj["subject"] as? String) ?? "" + let seed = SigilRenderer.seed(for: id + "\u{1}" + subject) + out.append(LedgerEntry( + id: id, host: selfHost, name: handle, subject: subject, + status: (obj["status"] as? String) ?? "running", + kind: "agent", seed: String(format: "%016llx", seed), + cwd: (obj["cwd"] as? String) ?? "", + updated: mtime.timeIntervalSince1970 * 1000)) + } + return out + } + + /// Append one line to the history log whenever the advertised set changes — + /// kept provenance the CLI or a human can walk back through. Trimmed so the + /// log never grows without bound. + private func recordHistory(_ entries: [LedgerEntry]) { + let fingerprint = entries.map { "\($0.id):\($0.name):\($0.status)" } + .sorted().joined(separator: ",") + guard fingerprint != lastPublishedFingerprint else { return } + lastPublishedFingerprint = fingerprint + let line = ["ts": Date().timeIntervalSince1970 * 1000, "host": selfHost, + "handles": entries.map { ["host": $0.host, "name": $0.name, + "id": $0.id, "status": $0.status] }] as [String: Any] + guard let data = try? JSONSerialization.data(withJSONObject: line), + let text = String(data: data, encoding: .utf8) else { return } + let path = Self.historyLog + if let fh = FileHandle(forWritingAtPath: path) { + fh.seekToEndOfFile(); fh.write(Data((text + "\n").utf8)); try? fh.close() + } else { + try? (text + "\n").write(toFile: path, atomically: true, encoding: .utf8) + } + trimHistory(path) + } + + private func trimHistory(_ path: String) { + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return } + let lines = text.split(separator: "\n", omittingEmptySubsequences: true) + guard lines.count > 500 else { return } + let kept = lines.suffix(500).joined(separator: "\n") + "\n" + try? kept.write(toFile: path, atomically: true, encoding: .utf8) + } + + // ── peer fetch (off-main, failure-tolerant) ────────────────────────── + private func fetchPeers(_ peers: [TailnetPeer]) { + for peer in peers { + guard let url = URL(string: "http://\(peer.ip):\(Self.port)/ledger") else { continue } + var req = URLRequest(url: url) + req.timeoutInterval = 2 + URLSession.shared.dataTask(with: req) { data, resp, _ in + guard let data, let http = resp as? HTTPURLResponse, http.statusCode == 200, + (try? JSONDecoder().decode(Ledger.self, from: data)) != nil + else { return } + // Re-wrap with fetch provenance, then persist the peer cache. + var obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + obj["fetchedAt"] = Date().timeIntervalSince1970 * 1000 + obj["fetchedFrom"] = peer.ip + guard let out = try? JSONSerialization.data(withJSONObject: obj, + options: [.prettyPrinted, .sortedKeys]) + else { return } + let safe = peer.hostname.replacingOccurrences(of: "/", with: "_") + let path = "\(LedgerStore.peersDir)/\(safe).json" + try? out.write(to: URL(fileURLWithPath: path), options: [.atomic]) + }.resume() + } + } + + // ── helpers ────────────────────────────────────────────────────────── + private func statusName(_ s: ClaudeSession.State) -> String { + switch s { + case .blank: return "blank" + case .working: return "working" + case .rendering: return "rendering" + case .complete: return "complete" + case .awaiting: return "awaiting" + case .interrupted: return "interrupted" + case .stale: return "stale" + } + } + + /// This machine's tailnet identity (hostname + IPv4) from `tailscale status + /// --json` → Self. Matches the naming peers advertise, so host keys line up + /// fleet-wide (neo, blueberry, …). + static func selfIdentity() -> (host: String, ip: String) { + guard let ts = Tools.resolve("tailscale"), + let out = ShellRunner.output(ts, args: ["status", "--json"], timeout: 2), + let data = out.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let me = json["Self"] as? [String: Any] + else { return ("", "") } + let host = (me["HostName"] as? String) ?? "" + let ips = (me["TailscaleIPs"] as? [String]) ?? [] + let ip = ips.first { $0.contains(".") } ?? "" + return (host, ip) + } +} + +// ── minimal tailnet HTTP server ────────────────────────────────────────── +// Serves the local ledger JSON, bound to a single IP (the tailscale address). +// One connection at a time is plenty for a ledger that peers poll every ~10s. +// Modeled on ACLogin's loopback SocketListener, but bound to the tailnet IP. +final class LedgerHTTPServer { + let boundIP: String + /// Called on `POST /poke` with the decoded JSON body — the owner marks the + /// referenced handle "observed". + var onPoke: (([String: Any]) -> Void)? + private var fd: Int32 = -1 + private let queue = DispatchQueue(label: "slab.ledger.http") + private var running = false + + init(ip: String, port: UInt16) throws { + boundIP = ip + fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw NSError(domain: "slab.ledger.socket", code: Int(errno)) } + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr(ip) // tailnet IP only — not INADDR_ANY + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 8) == 0 else { + close(fd); fd = -1 + throw NSError(domain: "slab.ledger.bind", code: Int(errno)) + } + running = true + queue.async { [weak self] in self?.acceptLoop() } + } + + func stop() { running = false; if fd >= 0 { close(fd); fd = -1 } } + + private func acceptLoop() { + while running { + let client = accept(fd, nil, nil) + if client < 0 { break } + handle(client) + } + } + + private func handle(_ client: Int32) { + defer { close(client) } + // A 2s recv timeout so a stalled client can never wedge the accept loop. + var tv = timeval(tv_sec: 2, tv_usec: 0) + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + + // Read headers, then — for a POST — keep reading until the declared + // Content-Length of body bytes has arrived. URLSession sends the header + // block and body in SEPARATE segments, so a single read() misses the + // body; curl happens to send both in one. Loop until complete. + var data = Data() + var buf = [UInt8](repeating: 0, count: 8192) + let marker = Data("\r\n\r\n".utf8) + var bodyStart: Int? + var contentLength = 0 + while true { + let n = read(client, &buf, buf.count) + if n <= 0 { break } + data.append(contentsOf: buf[0..= contentLength { break } + } + let raw = String(decoding: data, as: UTF8.self) + let line = raw.split(separator: "\r\n", maxSplits: 1).first.map(String.init) ?? "" + + // POST /poke — a peer read one of our handles. Decode the JSON body and + // mark it observed; reply {"ok":true}. + if line.hasPrefix("POST"), line.contains("/poke") { + if let bs = bodyStart, bs <= data.count, + let obj = try? JSONSerialization.jsonObject(with: data[bs...]) as? [String: Any] { + onPoke?(obj) + } + respond(client, body: Data("{\"ok\":true}".utf8)) + return + } + + // Anything else (GET /ledger) → the current local ledger JSON. + let body = (try? Data(contentsOf: URL(fileURLWithPath: LedgerStore.localFile))) + ?? Data("{\"host\":\"\",\"entries\":[]}".utf8) + respond(client, body: body) + } + + private func respond(_ client: Int32, body: Data) { + let header = "HTTP/1.0 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: \(body.count)\r\n" + + "Connection: close\r\n\r\n" + var out = Data(header.utf8); out.append(body) + _ = out.withUnsafeBytes { write(client, $0.baseAddress, $0.count) } + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/LedgerCLI.swift b/slab/menubar-swift/Sources/SlabMenubar/LedgerCLI.swift new file mode 100644 index 0000000000..b651410153 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/LedgerCLI.swift @@ -0,0 +1,153 @@ +// LedgerCLI.swift — the scriptable resolver an agent calls to turn a +// `host:name` reference into a concrete handle, reading only the kept on-disk +// cache (local + peers). No network, no running menubar required, O(small): +// +// slab-menubar ledger resolve neo:geb → matching handle(s) as JSON +// slab-menubar ledger resolve iris → search every host +// slab-menubar ledger dump → merged fleet ledger +// slab-menubar ledger hosts → advertised hosts + counts +// +// main.swift calls handleIfPresent() before any AppKit bootstrap, so the same +// binary doubles as a CLI without ever spawning a menubar. +import Foundation + +enum LedgerCLI { + /// Returns true if argv was a ledger subcommand (caller then exits). A + /// no-match / bad-usage still returns true — it's ours to answer. + static func handleIfPresent(_ argv: [String]) -> Bool { + guard argv.count >= 2, argv[1] == "ledger" else { return false } + let sub = argv.count >= 3 ? argv[2] : "resolve" + switch sub { + case "resolve": + let ref = argv.count >= 4 ? argv[3] : "" + resolve(ref) + case "dump": + printJSON(load().map(encodeEntry)) + case "hosts": + var counts: [String: Int] = [:] + for e in load() { counts[e.host, default: 0] += 1 } + printJSON(counts.keys.sorted().map { ["host": $0, "handles": counts[$0] ?? 0] as [String: Any] }) + default: + FileHandle.standardError.write(Data("usage: ledger [resolve | dump | hosts]\n".utf8)) + } + return true + } + + // ── resolution ───────────────────────────────────────────────────── + private static func resolve(_ ref: String) { + var host = "" + var query = ref + if let colon = ref.firstIndex(of: ":") { + host = String(ref[.. b.updated + } + poke(ranked) // tell each owner its handle was read + printJSON(ranked.map(encodeEntry)) + } + + /// Fire a best-effort "poke" beacon to each matched handle's owner so its + /// rock reacts to being read. Owner IP comes from the cached ledger; sends + /// are concurrent with a short overall cap so a slow/offline owner never + /// stalls the resolve. Set SLAB_POKE_BY to identify as an agent (iris, + /// hermes, …) rather than the bare hostname. + private static func poke(_ entries: [LedgerEntry]) { + let ips = hostIPs() + let by = ProcessInfo.processInfo.environment["SLAB_POKE_BY"] + ?? ProcessInfo.processInfo.hostName.replacingOccurrences(of: ".local", with: "") + let group = DispatchGroup() + for e in entries { + guard let ip = ips[e.host.lowercased()], !ip.isEmpty, + let url = URL(string: "http://\(ip):\(LedgerStore.port)/poke") else { continue } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.timeoutInterval = 2 + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let payload = ["id": e.id, "name": e.name, "by": by, + "ts": Date().timeIntervalSince1970 * 1000] as [String: Any] + req.httpBody = try? JSONSerialization.data(withJSONObject: payload) + group.enter() + URLSession.shared.dataTask(with: req) { _, _, _ in group.leave() }.resume() + } + _ = group.wait(timeout: .now() + 2) // let the beacons flush before we exit + } + + // ── cache loading (local + peers) ─────────────────────────────────── + private static func load() -> [LedgerEntry] { + var all: [LedgerEntry] = [] + all.append(contentsOf: entries(atPath: LedgerStore.localFile)) + if let names = try? FileManager.default.contentsOfDirectory(atPath: LedgerStore.peersDir) { + for n in names where n.hasSuffix(".json") { + all.append(contentsOf: entries(atPath: "\(LedgerStore.peersDir)/\(n)")) + } + } + return all + } + + private static func entries(atPath path: String) -> [LedgerEntry] { + guard let data = FileManager.default.contents(atPath: path), + let ledger = try? JSONDecoder().decode(Ledger.self, from: data) + else { return [] } + return ledger.entries + } + + /// host → tailscale IP, gleaned from every cached ledger (local + peers). + /// Lets the resolver reach an owner's `/poke` endpoint without a discovery + /// fork of its own. + private static func hostIPs() -> [String: String] { + var map: [String: String] = [:] + var paths = [LedgerStore.localFile] + if let names = try? FileManager.default.contentsOfDirectory(atPath: LedgerStore.peersDir) { + paths += names.filter { $0.hasSuffix(".json") }.map { "\(LedgerStore.peersDir)/\($0)" } + } + for p in paths { + guard let data = FileManager.default.contents(atPath: p), + let ledger = try? JSONDecoder().decode(Ledger.self, from: data), + !ledger.host.isEmpty, !ledger.ip.isEmpty else { continue } + map[ledger.host.lowercased()] = ledger.ip + } + return map + } + + // ── output ────────────────────────────────────────────────────────── + private static func encodeEntry(_ e: LedgerEntry) -> [String: Any] { + ["id": e.id, "host": e.host, "name": e.name, "subject": e.subject, + "status": e.status, "kind": e.kind, "seed": e.seed, "cwd": e.cwd, + "handle": "\(e.host):\(e.name)"] + } + + private static func printJSON(_ obj: Any) { + guard let data = try? JSONSerialization.data( + withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { print("[]"); return } + print(text) + } + + /// lowercase, non-alphanumerics → single '-', trimmed. Turns "flock theme" + /// and "Flock-Theme!" into the same "flock-theme" a reference would use. + private static func slug(_ s: String) -> String { + var out = "" + var lastDash = true + for ch in s.lowercased() { + if ch.isLetter || ch.isNumber { out.append(ch); lastDash = false } + else if !lastDash { out.append("-"); lastDash = true } + } + if out.hasSuffix("-") { out.removeLast() } + return out + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index bf26bb1911..71ab440877 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -284,6 +284,47 @@ final class PromptSigilOverlay { shadowLayer.opacity = h ? 0.72 : 0.9 } + /// "Someone is reading this prompt right now." Layered ON TOP of the + /// status motion (which stays the baseline): the stone blinks (opacity + /// pulse), rattles (a tight position shake), and tumbles much faster — + /// a visceral tell that a peer or agent just resolved this handle. The + /// sigil identity itself never changes; only motion + opacity react. The + /// controller flips this off when the observe window decays, restoring the + /// status (or hover) motion. + private var observed = false + func setObserved(_ active: Bool) { + guard observed != active else { return } + observed = active + if active { + let blink = CABasicAnimation(keyPath: "opacity") + blink.fromValue = 1.0; blink.toValue = 0.3 + blink.duration = 0.22; blink.autoreverses = true + blink.repeatCount = .infinity; blink.isRemovedOnCompletion = false + rockLayer.add(blink, forKey: "observedBlink") + nameLayer.add(blink, forKey: "observedBlink") + + let c = rockLayer.position + let shake = CAKeyframeAnimation(keyPath: "position") + shake.values = [c, CGPoint(x: c.x - 2.4, y: c.y + 1.4), + CGPoint(x: c.x + 2.2, y: c.y - 1.2), + CGPoint(x: c.x - 1.6, y: c.y - 1.8), c].map { NSValue(point: $0) } + shake.duration = 0.13; shake.repeatCount = .infinity + shake.isRemovedOnCompletion = false + rockLayer.add(shake, forKey: "observedShake") + + retime(rockLayer, speed: 3.2) + retime(shadowMask, speed: 3.2) + } else { + rockLayer.removeAnimation(forKey: "observedBlink") + nameLayer.removeAnimation(forKey: "observedBlink") + rockLayer.removeAnimation(forKey: "observedShake") + rockLayer.opacity = 1.0 + nameLayer.opacity = 1.0 + retime(rockLayer, speed: hovered ? 2.6 : 1.0) + retime(shadowMask, speed: hovered ? 2.6 : 1.0) + } + } + /// Rebuild the name as MacPal-style bubble letters: one RockCharLayer per /// character in Comic Sans — white fill, dark outline, a hard offset /// shadow in the session's status colour — each with a deterministic @@ -822,6 +863,7 @@ final class PromptSigilOverlayController { installActivationObserverIfNeeded() installAXFocusObservers() installMouseMonitorsIfNeeded() + installObservedObserverIfNeeded() let live = sessions.filter { !$0.tty.isEmpty && $0.remoteHost.isEmpty } let liveIds = Set(live.map { $0.sessionId }) @@ -906,6 +948,17 @@ final class PromptSigilOverlayController { return summary + "\n" + subject } + /// Wake the reposition loop the instant a poke lands so the rock reacts + /// now, not at the next lazy idle tick. Installed once. + private var observedObserverInstalled = false + private func installObservedObserverIfNeeded() { + guard !observedObserverInstalled else { return } + observedObserverInstalled = true + NotificationCenter.default.addObserver( + forName: LedgerStore.observedNote, object: nil, queue: .main + ) { [weak self] _ in self?.scheduleTick(after: 0) } + } + private func teardown() { timer?.invalidate(); timer = nil for (_, ov) in overlays { ov.close() } @@ -946,10 +999,18 @@ final class PromptSigilOverlayController { reposition() // refresh targets + z-order guard !overlays.isEmpty else { timer = nil; return } var settling = false - for (_, ov) in overlays where ov.advance(dt: dt) { settling = true } - // Stay at display rate while a window moved recently OR a badge is still - // catching up to its target; otherwise drop to the idle poll. - let active = now < motionDeadline || settling + var anyObserved = false + for (sid, ov) in overlays { + if ov.advance(dt: dt) { settling = true } + // "Being read" reaction — on while the poke window is live, off once + // it decays. Cheap dict lookup; the blink/shake/spin run server-side. + let obs = LedgerStore.shared.observation(for: sid) != nil + ov.setObserved(obs) + if obs { anyObserved = true } + } + // Stay at display rate while a window moved recently, a badge is still + // catching up, or a rock is reacting to being observed. + let active = now < motionDeadline || settling || anyObserved scheduleTick(after: active ? activeInterval : idleInterval) } diff --git a/slab/menubar-swift/Sources/SlabMenubar/main.swift b/slab/menubar-swift/Sources/SlabMenubar/main.swift index 03d86b96ba..7dd61ba2ea 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/main.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/main.swift @@ -1,5 +1,9 @@ import AppKit +// CLI fast-path: `slab-menubar ledger …` resolves handles from the kept +// on-disk cache and exits before any menubar bootstrap. +if LedgerCLI.handleIfPresent(CommandLine.arguments) { exit(0) } + let app = NSApplication.shared app.setActivationPolicy(.accessory) let delegate = AppDelegate() diff --git a/slab/menubar-swift/install.sh b/slab/menubar-swift/install.sh index 97f6fa209b..8a281b9944 100755 --- a/slab/menubar-swift/install.sh +++ b/slab/menubar-swift/install.sh @@ -287,6 +287,19 @@ if [[ -e "${LEGACY_BIN}" ]]; then warn "removed legacy binary at ${LEGACY_BIN} — you may want to clear its old Accessibility entry in System Settings" fi +# slab-ledger shim: a scriptable resolver on PATH so an agent can turn a +# `host:name` reference into JSON (and poke the owner's rock) without knowing +# the bundle path. Just execs the menubar binary's `ledger` subcommand. +LEDGER_SHIM="${REPO_HOME}/.local/bin/slab-ledger" +mkdir -p "$(dirname "${LEDGER_SHIM}")" +cat > "${LEDGER_SHIM}" < "${PLIST_PATH}" -- 2.51.2 From 353f6e18137806576495a7ace0c1185c59c75246 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 6 Jul 2026 15:20:01 -0700 Subject: [PATCH 2/3] slab: ledger fetch over tailnet + short-hostname handles + poke logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit field fixes from verifying neo↔blueberry: - Info.plist: allow cleartext on the tailnet (ATS was silently blocking the app's peer fetch; the tailnet is the auth perimeter, OAuth stays https). - selfIdentity uses the SHORT os hostname (neo, blueberry) not tailscale's device label ("Jeffrey's MacBook Neo"), so `host:name` refs line up fleet-wide; peer cache files are named by the ledger's own host. - LedgerHTTPServer reads until the full Content-Length body arrives — URLSession splits header + body across segments (curl didn't), so single-read pokes were dropped. - a rock logs once when it starts reacting to a read (" reacting — read by X"), the owner-side tell that the observe render path fired. Co-Authored-By: Claude Opus 4.8 (1M context) --- slab/menubar-swift/Info.plist | 8 +++++ .../Sources/SlabMenubar/Ledger.swift | 31 ++++++++++++------- .../SlabMenubar/PromptSigilOverlay.swift | 17 +++++++--- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/slab/menubar-swift/Info.plist b/slab/menubar-swift/Info.plist index 0c9f9a87da..84585c826a 100644 --- a/slab/menubar-swift/Info.plist +++ b/slab/menubar-swift/Info.plist @@ -26,5 +26,13 @@ NSHighResolutionCapable + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index efdf75d2ae..d33ef0b0ad 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -271,16 +271,18 @@ final class LedgerStore { req.timeoutInterval = 2 URLSession.shared.dataTask(with: req) { data, resp, _ in guard let data, let http = resp as? HTTPURLResponse, http.statusCode == 200, - (try? JSONDecoder().decode(Ledger.self, from: data)) != nil + let ledger = try? JSONDecoder().decode(Ledger.self, from: data) else { return } - // Re-wrap with fetch provenance, then persist the peer cache. + // Re-wrap with fetch provenance, then persist the peer cache — + // named by the ledger's own host so the file reads as peers/neo.json. var obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] obj["fetchedAt"] = Date().timeIntervalSince1970 * 1000 obj["fetchedFrom"] = peer.ip guard let out = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) else { return } - let safe = peer.hostname.replacingOccurrences(of: "/", with: "_") + let host = ledger.host.isEmpty ? peer.hostname : ledger.host + let safe = host.replacingOccurrences(of: "/", with: "_") let path = "\(LedgerStore.peersDir)/\(safe).json" try? out.write(to: URL(fileURLWithPath: path), options: [.atomic]) }.resume() @@ -304,15 +306,20 @@ final class LedgerStore { /// --json` → Self. Matches the naming peers advertise, so host keys line up /// fleet-wide (neo, blueberry, …). static func selfIdentity() -> (host: String, ip: String) { - guard let ts = Tools.resolve("tailscale"), - let out = ShellRunner.output(ts, args: ["status", "--json"], timeout: 2), - let data = out.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let me = json["Self"] as? [String: Any] - else { return ("", "") } - let host = (me["HostName"] as? String) ?? "" - let ips = (me["TailscaleIPs"] as? [String]) ?? [] - let ip = ips.first { $0.contains(".") } ?? "" + // Host is the SHORT OS hostname (neo, blueberry) — the name the fleet + // references, not tailscale's device label ("Jeffrey's MacBook Neo"). + // IP is this machine's tailscale v4, for binding + advertising. + let raw = ProcessInfo.processInfo.hostName + let host = raw.split(separator: ".").first.map { $0.lowercased() } ?? raw.lowercased() + var ip = "" + if let ts = Tools.resolve("tailscale"), + let out = ShellRunner.output(ts, args: ["status", "--json"], timeout: 2), + let data = out.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let me = json["Self"] as? [String: Any] { + let ips = (me["TailscaleIPs"] as? [String]) ?? [] + ip = ips.first { $0.contains(".") } ?? "" + } return (host, ip) } } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index 71ab440877..d7603f2cb1 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -292,8 +292,11 @@ final class PromptSigilOverlay { /// controller flips this off when the observe window decays, restoring the /// status (or hover) motion. private var observed = false - func setObserved(_ active: Bool) { - guard observed != active else { return } + /// Returns true when the observed state actually flipped (so the controller + /// can log the reaction exactly once per poke burst). + @discardableResult + func setObserved(_ active: Bool) -> Bool { + guard observed != active else { return false } observed = active if active { let blink = CABasicAnimation(keyPath: "opacity") @@ -323,6 +326,7 @@ final class PromptSigilOverlay { retime(rockLayer, speed: hovered ? 2.6 : 1.0) retime(shadowMask, speed: hovered ? 2.6 : 1.0) } + return true } /// Rebuild the name as MacPal-style bubble letters: one RockCharLayer per @@ -1004,9 +1008,12 @@ final class PromptSigilOverlayController { if ov.advance(dt: dt) { settling = true } // "Being read" reaction — on while the poke window is live, off once // it decays. Cheap dict lookup; the blink/shake/spin run server-side. - let obs = LedgerStore.shared.observation(for: sid) != nil - ov.setObserved(obs) - if obs { anyObserved = true } + if let obs = LedgerStore.shared.observation(for: sid) { + if ov.setObserved(true) { NSLog("🪨 [ledger] \(ov.name) reacting — read by \(obs.by)") } + anyObserved = true + } else { + ov.setObserved(false) + } } // Stay at display rate while a window moved recently, a badge is still // catching up, or a rock is reacting to being observed. -- 2.51.2 From ef211a496de8ba458383fb002d1878f59f206e17 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 6 Jul 2026 16:01:48 -0700 Subject: [PATCH 3/3] menubar-fit: wand out-ranks Menu Band + wizard pinned left of it DateWizard priority 20->40 and Menu Band 40->20 so the broker shrinks Menu Band's piano-key ladder before the wand sheds its countdown badge (the prior ordering did the reverse). Both status items now set an autosaveName and seed 'NSStatusItem Preferred Position' (wizard 24 > menuband 8) so the wand sits to the LEFT of Menu Band; a user Cmd-drag still persists over it. Co-Authored-By: Claude Opus 4.8 (1M context) --- date-wizard/Sources/DateWizard/MenuBarDays.swift | 16 ++++++++++++---- slab/menuband/Sources/MenuBand/AppDelegate.swift | 11 ++++++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/date-wizard/Sources/DateWizard/MenuBarDays.swift b/date-wizard/Sources/DateWizard/MenuBarDays.swift index 1c56ae7cdc..dacdf7576a 100644 --- a/date-wizard/Sources/DateWizard/MenuBarDays.swift +++ b/date-wizard/Sources/DateWizard/MenuBarDays.swift @@ -45,7 +45,15 @@ final class MenuBarDays { // ── lifecycle ───────────────────────────────────────────────────── func install() { barThickness = NSStatusBar.system.thickness + // Pin the wand LEFT of Menu Band. NSStatusItem has no absolute-ordering + // API; its autosaveName + "Preferred Position" default is the lever macOS + // honors (higher = further left). Seed once; a user ⌘-drag persists over it. + let posKey = "NSStatusItem Preferred Position datewizard" + if UserDefaults.standard.object(forKey: posKey) == nil { + UserDefaults.standard.set(24, forKey: posKey) + } statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.autosaveName = "datewizard" if let button = statusItem.button { self.button = button button.imagePosition = .imageOnly @@ -131,13 +139,13 @@ final class MenuBarDays { // ── menu-bar-fit negotiation ────────────────────────────────────── // The wand's ladder: bare → presence-dot → full countdown pill. With no // upcoming event the ladder collapses to just the bare wand so the broker's - // width model stays honest (nothing to shed). Low priority (20): the badge - // is glanceable-but-redundant with the calendar, so DateWizard sheds it - // before Menu Band gives up piano keys. + // width model stays honest (nothing to shed). High priority (40): the wand's + // countdown is the thing to defend, so Menu Band gives up piano keys before + // DateWizard sheds the badge. private func startFitNegotiation() { guard let statusItem, fit == nil else { return } let has = (nextEventDate != nil) - fit = MenuBarFit(slug: "datewizard", priority: 20, + fit = MenuBarFit(slug: "datewizard", priority: 40, rungs: fitRungs(hasNext: has), statusItem: statusItem, startAt: has ? 2 : 0) { [weak self] _, idx in self?.fitRung = idx diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index b63b98eee3..f7997d3bde 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -698,7 +698,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { KeyboardIconRenderer.iconScale = max(1.0, min(1.6, (barThickness - 0.5) / baseIconH)) } + // Keep Menu Band to the RIGHT of the wand: lower preferred position than + // datewizard's (higher = further left). Seed once; a ⌘-drag persists over it. + let mbPosKey = "NSStatusItem Preferred Position menuband" + if UserDefaults.standard.object(forKey: mbPosKey) == nil { + UserDefaults.standard.set(8, forKey: mbPosKey) + } statusItem = NSStatusBar.system.statusItem(withLength: KeyboardIconRenderer.imageSize.width) + statusItem.autosaveName = "menuband" debugLog("statusItem created, button=\(statusItem.button != nil) length=\(statusItem.length)") if let button = statusItem.button { let cell = NoHighlightStatusBarCell() @@ -1995,7 +2002,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { fitLayouts = layouts let startIdx = layouts.firstIndex(of: saved) ?? layouts.count - 1 - fit = MenuBarFit(slug: "menuband", priority: 40, rungs: rungs, + // priority 20 (below datewizard's 40): Menu Band yields piano keys before + // the wand sheds its countdown badge. + fit = MenuBarFit(slug: "menuband", priority: 20, rungs: rungs, statusItem: statusItem, startAt: startIdx) { [weak self] _, idx in guard let self, idx >= 0, idx < self.fitLayouts.count else { return } KeyboardIconRenderer.displayLayout = self.fitLayouts[idx]