From d5d19fcddee104ddd4737257de65df26417f4d37 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 15:19:22 -0700 Subject: [PATCH] Slab: Loopboy mode state, menubar tiling and ledger updates, prompt and session logging --- slab/bin/claude-prompt-log.sh | 11 +- slab/bin/codex-session-watch.mjs | 8 + slab/lib/loopboy-mode.mjs | 37 ++++ .../Sources/SlabMenubar/AXTiler.swift | 14 +- .../Sources/SlabMenubar/AppDelegate.swift | 187 ++---------------- .../Sources/SlabMenubar/Ledger.swift | 24 ++- .../Sources/SlabMenubar/LoopboyRoutes.swift | 29 +-- .../Sources/SlabMenubar/MenuBuilder.swift | 2 +- .../Sources/SlabMenubar/SigilRenderer.swift | 3 + slab/menubar-swift/tests/loopboy-mode.sh | 51 +++++ slab/test/loopboy-mode.test.mjs | 142 +++++++++++++ 11 files changed, 318 insertions(+), 190 deletions(-) create mode 100644 slab/lib/loopboy-mode.mjs create mode 100755 slab/menubar-swift/tests/loopboy-mode.sh create mode 100644 slab/test/loopboy-mode.test.mjs diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh index 154f0302ff..b96e9610a8 100755 --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -46,10 +46,13 @@ if [[ -n "$input" ]]; then ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) started_at=$(jq -r '.started_at // empty' "$ACTIVE_DIR/$session_id" 2>/dev/null || true) [[ -n "$started_at" ]] || started_at=$ts - # A launched Loopboy carries its contact in the environment; an - # adopted one (prox_bind_notification adopt=true) carries it only on - # this marker, so keep the stamped value across rewrites. - contact=${SLAB_LOOPBOY_CONTACT:-$(jq -r '.loopboy_contact // empty' "$ACTIVE_DIR/$session_id" 2>/dev/null || true)} + # An explicit mode (including OFF) wins over immutable launch env. + mode_file="$SLAB_HOME/state/loopboy-modes/$session_id.json" + if [[ -f "$mode_file" ]]; then + contact=$(jq -r --arg sid "$session_id" 'if .sessionId == $sid then .contact // "" else "" end' "$mode_file" 2>/dev/null || true) + else + contact=${SLAB_LOOPBOY_CONTACT:-$(jq -r '.loopboy_contact // empty' "$ACTIVE_DIR/$session_id" 2>/dev/null || true)} + fi # 4–8 word summary used as the live Terminal title and the menubar's # short subject. We collapse whitespace, take the first 7 words, and diff --git a/slab/bin/codex-session-watch.mjs b/slab/bin/codex-session-watch.mjs index cc28343b39..f00aaaa921 100755 --- a/slab/bin/codex-session-watch.mjs +++ b/slab/bin/codex-session-watch.mjs @@ -162,6 +162,14 @@ async function updateMarker(patch) { }; try { Object.assign(obj, JSON.parse(await readFile(ACTIVE, "utf8"))); } catch {} Object.assign(obj, patch, { updated: nowISO() }); + try { + const mode = JSON.parse(await readFile(join(SLAB_HOME, "state", "loopboy-modes", `${sid}.json`), "utf8")); + if (mode.sessionId === sid) { + obj.loopboy_contact = mode.contact || ""; + if (!mode.contact) { obj.loopboy_state = ""; obj.loopboy_response = ""; } + } + } catch {} + try { await writeFile(ACTIVE, JSON.stringify(obj)); } catch {} } const rm = async (p) => { try { await unlink(p); } catch {} }; diff --git a/slab/lib/loopboy-mode.mjs b/slab/lib/loopboy-mode.mjs new file mode 100644 index 0000000000..a463dfc23d --- /dev/null +++ b/slab/lib/loopboy-mode.mjs @@ -0,0 +1,37 @@ +// Mutable session mode, separate from launch-time environment and live markers. +// An explicit empty contact persists an exit even if an old watcher rewrites +// its launch contact. No process, terminal, or provider history is touched. +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +export function modePath(id, env = process.env) { + if (!/^[A-Za-z0-9._-]{1,180}$/.test(id) || id === "." || id === "..") { + throw new Error("Loopboy mode requires a valid session id"); + } + return join(env.SLAB_HOME || join(homedir(), ".local/share/slab"), "state/loopboy-modes", `${id}.json`); +} + +export async function readLoopboyMode(id, env) { + let text; + try { text = await readFile(modePath(id, env), "utf8"); } + catch (error) { if (error.code === "ENOENT") return null; throw error; } + try { + const mode = JSON.parse(text); + if (mode.sessionId === id && typeof mode.contact === "string") return mode; + } catch {} + // Fail closed without making one corrupt mode hide the entire fleet ledger. + return { sessionId: id, contact: "", invalid: true }; +} + +export async function writeLoopboyMode(id, { contact = "", name = "" }, env) { + if (contact && !/^[a-z0-9_-]{1,40}$/.test(contact)) throw new Error("Invalid Loopboy contact key"); + const file = modePath(id, env); + await mkdir(join(file, ".."), { recursive: true, mode: 0o700 }); + const mode = { version: 1, sessionId: id, contact, name, changedAt: new Date().toISOString() }; + const temp = `${file}.${randomUUID()}.tmp`; + await writeFile(temp, JSON.stringify(mode) + "\n", { mode: 0o600 }); + await rename(temp, file); + return mode; +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift b/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift index c8c23c94f9..f9e537d2d0 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AXTiler.swift @@ -44,6 +44,9 @@ enum AXTiler { let terminal: [Window] let acPanes: [Window] let chrome: [Window] + /// Messenger windows (Signal Desktop): ordinary grid cells, found + /// by bundle id like Chrome. + let messengers: [Window] /// Stage windows: a game stream (GeForce NOW) that wants one big /// column of its own rather than a grid cell. Only the first stage /// window is staged; any extra ones fall into the ordinary grid. @@ -53,7 +56,7 @@ enum AXTiler { /// binaries with no bundle identifier. let wizards: [Window] - var all: [Window] { iterm + terminal + acPanes + chrome + wizards + stage } + var all: [Window] { iterm + terminal + acPanes + chrome + messengers + wizards + stage } var signature: [CGWindowID] { all.map(\.id).sorted() } } @@ -63,6 +66,9 @@ enum AXTiler { /// measuring what the window accepted rather than assuming. static let stageBundleIDs = [GameMode.gfnBundleID] + /// Messenger apps that tile as equal grid cells beside the terminals. + static let messengerBundleIDs = ["org.whispersystems.signal-desktop"] + /// The wizard roster (date-wizard/…/WizardRoster.swift plus the wizards /// it omits), by executable name. `swift build` products carry no /// CFBundleIdentifier, so `NSRunningApplication` is matched on the @@ -129,6 +135,9 @@ enum AXTiler { + windowRefs(bundleId: "computer.aesthetic.nopaint", liveWindows: liveWindows) + easelWindowRefs(liveWindows: liveWindows), chrome: windowRefs(bundleId: "com.google.Chrome", liveWindows: liveWindows), + messengers: messengerBundleIDs.flatMap { + windowRefs(bundleId: $0, liveWindows: liveWindows) + }, stage: stageBundleIDs.flatMap { windowRefs(bundleId: $0, liveWindows: liveWindows) }, @@ -154,6 +163,9 @@ enum AXTiler { requireGeometry: false) + windowRefs(bundleId: "com.google.Chrome", liveWindows: liveWindows, requireGeometry: false) + + messengerBundleIDs.flatMap { + windowRefs(bundleId: $0, liveWindows: liveWindows, requireGeometry: false) + } + wizardWindowRefs(liveWindows: liveWindows, requireGeometry: false) + stageBundleIDs.flatMap { windowRefs(bundleId: $0, liveWindows: liveWindows, requireGeometry: false) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 78ddf7058a..dc210a4282 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -137,16 +137,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var imsgStatus = "β€”" private var imsgConfigured = false private var imsgUnread = 0 - /// Contact-keyed Loopboy heartbeat state. Unlike the global inbox accent, - /// this colors and wakes only the session bound to that contact. + /// Loopboy notifications are passive: pulse the rock and queue arrivals. private var loopboyPendingContacts = Set() - private var loopboyHeartbeatAt: [String: Date] = [:] - /// Last context actually handed to each Loopboy evaluator. Unchanged - /// unresolved work receives only a slow retry lease; ordinary heartbeats - /// become observation-only and spend no agent turn/tokens. - private var loopboyEvaluatedFingerprint: [String: String] = [:] - private var loopboyEvaluatedAt: [String: Date] = [:] - private var loopboyWakeInFlight = Set() private var lastLoopboyFleetHeartbeat = Date.distantPast private var loopboyHeartbeatVisibleUntil = Date.distantPast private var signalPending = false @@ -941,10 +933,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self.imsgUnread = contactPending.reduce(0) { $0 + (($1["pending"] as? Int) ?? 0) } - // A callback handles the edge immediately; this heartbeat also - // re-steers an idle/completed client loop while work remains. - // Bound the cadence so a persistent pending thread cannot - // flood the TTY with prompts. + // The heartbeat is visual only. It never creates an agent turn. let heartbeatNow = Date() let fleetBeatDue = heartbeatNow.timeIntervalSince( self.lastLoopboyFleetHeartbeat) >= 60 @@ -958,61 +947,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { self.applyTerminalDecor() } } - let bindings = self.loopboySessionLabels() - let autoRespondContacts = self.loopboyAutoRespondContacts() - let heartbeatContacts = Set(contactPending.compactMap { row -> String? in - guard fleetBeatDue, ((row["pending"] as? Int) ?? 0) > 0, - let contact = row["contact"] as? String, - let sid = bindings.first(where: { $0.value == contact })?.key, - let session = self.state.claudeSessions.first(where: { - $0.sessionId == sid - }) else { return nil } - let threadFingerprint = (row["contextFingerprint"] as? String) ?? "" - let fingerprint = "\(threadFingerprint)|\(session.titleString)" - let changed = self.loopboyEvaluatedFingerprint[contact] != fingerprint - let retryDue = heartbeatNow.timeIntervalSince( - self.loopboyEvaluatedAt[contact] ?? .distantPast) >= 300 - guard changed || retryDue else { return nil } - switch session.state { - case .working, .rendering: - // A crashed/finished native resume can leave its marker - // saying WORKING. Fresh activity is protected; a full - // heartbeat with no update means the loop is halted. - return heartbeatNow.timeIntervalSince(session.updated) >= 60 - ? contact : nil - case .blank, .complete, .awaiting, .interrupted, .stale: - return session.loopboyState == "responding" - && !autoRespondContacts.contains(contact) ? nil : contact - } - }) - for contact in heartbeatContacts { - guard let row = contactPending.first(where: { - ($0["contact"] as? String) == contact - }) else { continue } - let display = (row["displayName"] as? String) ?? contact - let last = row["last"] as? [String: Any] - self.bumpBoundProx(contact: contact, displayLabel: display, - message: (last?["text"] as? String) ?? "", - fromMe: (last?["fromMe"] as? Bool) ?? false, - heartbeat: true) - self.loopboyHeartbeatAt[contact] = heartbeatNow - let threadFingerprint = (row["contextFingerprint"] as? String) ?? "" - let sid = bindings.first(where: { $0.value == contact })?.key - let topic = sid.flatMap { id in - self.state.claudeSessions.first(where: { $0.sessionId == id }) - }?.titleString ?? "" - self.loopboyEvaluatedFingerprint[contact] = "\(threadFingerprint)|\(topic)" - self.loopboyEvaluatedAt[contact] = heartbeatNow - } - self.loopboyHeartbeatAt = self.loopboyHeartbeatAt.filter { - pendingNow.contains($0.key) - } - self.loopboyEvaluatedFingerprint = self.loopboyEvaluatedFingerprint.filter { - pendingNow.contains($0.key) - } - self.loopboyEvaluatedAt = self.loopboyEvaluatedAt.filter { - pendingNow.contains($0.key) - } if newSinceLast || !arrivals.isEmpty { self.imsgArrivalVisibleUntil = Date().addingTimeInterval(15) } @@ -1096,81 +1030,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { || Date() < imsgArrivalVisibleUntil } - /// Poke the prox explicitly assigned to iMessage awareness and optionally - /// submit a small steering prompt to its live TTY. This is deliberately - /// opt-in via an untracked binding file; Slab never guesses which agent to - /// wake. A route may separately opt into a validated automatic response. + /// Arrivals use the prox inbox; no focus changes, typing, Return, or resume. + /// Legacy wake/autoRespond flags cannot enable terminal input here. private func bumpBoundProx(contact: String, displayLabel: String, message: String, - fromMe: Bool = false, heartbeat: Bool = false) { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any], - let loop = loops[contact] as? [String: Any], - let sid = loop["sessionId"] as? String, !sid.isEmpty else { return } - let wake = (loop["wake"] as? Bool) ?? false - let autoRespond = (loop["autoRespond"] as? Bool) ?? false - LedgerStore.shared.pokeLocal(sessionId: sid, by: "loopboy:\(contact)") - guard wake else { return } - - let clean = message.replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - let excerpt = String(clean.prefix(240)) - let direction = fromMe ? "outgoing to" : "incoming from" - let boundSession = state.claudeSessions.first(where: { $0.sessionId == sid }) - let prompt: String - if heartbeat { - // This is a stable conversation, not a stateless cron job. Route - // setup and actual message callbacks carry policy/context. Reuse - // the prompt hook's inferred title so each heartbeat names this - // prox's actual mission instead of asking a context-free "what's - // next?" every minute. - let topic = boundSession?.titleString - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - prompt = topic.isEmpty || topic == "(no subject)" - ? "Re-read the latest thread with \(displayLabel), infer the next concrete action, and do it." - : "Continue \(topic) for \(displayLabel): infer the next concrete action from the latest thread, then do it." - } else { - let update = excerpt.isEmpty - ? "Loopboy detected a thread update with \(displayLabel). Read the latest incoming and outgoing messages, then continue the client loop." - : "Loopboy detected a new \(direction) \(displayLabel): \(excerpt) β€” read the latest incoming and outgoing messages, then continue the client loop." - prompt = autoRespond - ? update + " This route explicitly authorizes automatic responses: after completing and validating any work, reread the newest thread context, discard stale drafts, send one appropriate reply using `node slab/bin/imsg.mjs send --to \(contact)`, and verify it appears outbound. Never duplicate a response." - : update + " Do not send or react automatically." - } - let providerId = boundSession?.providerSessionId ?? "" - let nudgeScreen = boundSession?.nudgeScreen ?? "" - let sessionCwd = boundSession?.cwd ?? Paths.acRepo - let agentType = boundSession?.agentType ?? "claude" - guard let tty = ttyForSession(sid) else { - NSLog("πŸ’¬ [loopboy] \(contact) prox \(sid.prefix(8)) has no live tty") - return - } - guard !loopboyWakeInFlight.contains(contact) else { - NSLog("πŸ’¬ [loopboy] \(contact) wake already in flight; coalescing") - return - } - loopboyWakeInFlight.insert(contact) - if heartbeat { - PromptSigilOverlayController.shared.flyPrompt(sessionId: sid, text: prompt) - } - wakeTerminal(tty: tty, prompt: prompt, providerSessionId: providerId, - nudgeScreen: nudgeScreen, cwd: sessionCwd, - agentType: agentType) { [weak self] status in - DispatchQueue.main.async { - self?.loopboyWakeInFlight.remove(contact) - NSLog("πŸ’¬ [loopboy] \(contact) wake finished status=\(status) prox=\(sid.prefix(8))") - if status == 2 || status == 3 { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.bumpBoundProx(contact: contact, - displayLabel: displayLabel, - message: message, - fromMe: fromMe, - heartbeat: heartbeat) - } - } - } - } - NSLog("πŸ’¬ [loopboy] \(contact) poked + starting wake prox \(sid.prefix(8)) on \(tty)") + fromMe: Bool = false) { + guard let route = LoopboyRoutes.all()[contact], + let session = state.claudeSessions.first(where: { $0.sessionId == route.sessionId }), + LoopboyRoutes.verifiedContact(for: session) == contact else { return } + LedgerStore.shared.pokeLocal(sessionId: session.sessionId, by: "loopboy:\(contact)") + let direction = fromMe ? "outgoing" : "incoming" + let excerpt = String(message.prefix(500)) + LedgerStore.shared.queueInbox([ + "from": "loopboy:\(contact)", "to_id": session.sessionId, + "text": "iMessage update with \(displayLabel) (\(direction)).\nPreview: \(excerpt)\nThis notification does not authorize sending or reacting.", + "urgency": "queue", + ]) } private func ttyForSession(_ sid: String) -> String? { @@ -1209,7 +1083,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Easel owns an embedded PTY, not a Terminal.app window. Focus the exact /// desktop host process advertised by its marker, then use the same trusted - /// keyboard event path as Loopboy/prox terminal wakes. + /// keyboard event path as explicit prox terminal wakes. private func wakeEasel(pid: Int, windowID: Int, prompt: String, completion: @escaping (Int32) -> Void) { let previousApp = NSWorkspace.shared.frontmostApplication @@ -1245,27 +1119,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// visually different from an ordinary manually-launched prox even while /// both agents share the same working/awaiting state. private func loopboySessionLabels() -> [String: String] { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] else { return [:] } - var labels: [String: String] = [:] - for (contact, value) in loops { - guard let loop = value as? [String: Any], - let sid = loop["sessionId"] as? String, !sid.isEmpty else { continue } - labels[sid] = contact - } - return labels - } - - private func loopboyAutoRespondContacts() -> Set { - guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let loops = obj["loops"] as? [String: Any] else { return [] } - return Set(loops.compactMap { contact, value in - guard let loop = value as? [String: Any], - (loop["autoRespond"] as? Bool) == true else { return nil } - return contact - }) + LoopboyRoutes.verifiedBySession(state.claudeSessions) } private func wakeTerminal(tty: String, prompt: String, @@ -4051,11 +3905,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @discardableResult private static func axTilePass(snapshot: AXTiler.Snapshot, geom: ScreenGeom, textSize: TextSize) -> AXPass? { - NSLog("🧩 [tile] windows=%d ids=%@ iterm=%d term=%d acpane=%d chrome=%d wizard=%d stage=%d", + NSLog("🧩 [tile] windows=%d ids=%@ iterm=%d term=%d acpane=%d chrome=%d messenger=%d wizard=%d stage=%d", snapshot.all.count, snapshot.signature.map(String.init).joined(separator: ","), snapshot.iterm.count, snapshot.terminal.count, snapshot.acPanes.count, - snapshot.chrome.count, snapshot.wizards.count, snapshot.stage.count) + snapshot.chrome.count, snapshot.messengers.count, snapshot.wizards.count, + snapshot.stage.count) // A stage window (GeForce NOW) is an ordinary, equal grid cell first. // Only when the app clamps above its cell β€” its configured floor is // bigger than the grid can offer β€” does it get a column of its own, diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index e585f745a7..e99cda8b84 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -89,7 +89,7 @@ final class LedgerStore { static let observedNote = Notification.Name("slab.ledger.observed") /// 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. + /// wake primitive used only for explicit prox_wake requests. 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 @@ -120,6 +120,16 @@ final class LedgerStore { queue.async { [weak self] in self?.ensureServer() } } + /// Share the existing socket/file inbox delivery with passive notifications. + func queueInbox(_ message: [String: Any]) { + queue.async { + let result = LedgerHTTPServer.inboxSend(message) + if (result["ok"] as? Bool) != true { + NSLog("πŸ’¬ [loopboy] inbox delivery failed: \(result["error"] ?? "unknown")") + } + } + } + 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, @@ -348,7 +358,7 @@ final class LedgerStore { /// Validate and enqueue a bounded prox continuation. The HTTP server never /// touches Accessibility, the pasteboard, or Terminal directly; all UX is - /// owned by AppDelegate's shared Loopboy re-entry path on the main queue. + /// owned by AppDelegate's explicit prox wake path on the main queue. private func receiveWake(_ body: [String: Any]) -> [String: Any] { let sid = ((body["id"] as? String) ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -419,7 +429,7 @@ final class LedgerStore { memoir: ProxMemoirs.shared.text(for: s.sessionId), agentType: s.agentType, platformTarget: s.platformTarget.isEmpty ? nil : s.platformTarget, - loopboyContact: s.loopboyContact.isEmpty ? nil : s.loopboyContact, + loopboyContact: LoopboyRoutes.verifiedContact(for: s), scanURL: s.scanURL.isEmpty ? nil : s.scanURL) } // The Easel address is the rock's own name, never the piece's, so @@ -620,7 +630,7 @@ final class LedgerHTTPServer { /// referenced handle "observed". var onPoke: (([String: Any]) -> Void)? /// Called on POST /wake after JSON framing. The owner validates and queues - /// re-entry through the menubar's shared Loopboy wake path. + /// re-entry through the menubar's explicit prox wake path. var onWake: (([String: Any]) -> [String: Any])? /// Called on POST /launch. The callback owns validation and returns a /// compact JSON-safe result dictionary. @@ -720,7 +730,7 @@ final class LedgerHTTPServer { // POST /send β€” a message for one of our sessions' inboxes. Written to // disk (socket first when a harness listens), never typed anywhere. if line.hasPrefix("POST"), line.contains("/send") { - let result = inboxSend(decodedBody(data, bodyStart: bodyStart)) + let result = Self.inboxSend(decodedBody(data, bodyStart: bodyStart)) let body = (try? JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])) ?? Data("{\"ok\":false,\"error\":\"encoding failed\"}".utf8) respond(client, body: body) @@ -811,7 +821,7 @@ final class LedgerHTTPServer { // messages.jsonl (dir 0700, file 0600) for the session's next turn. private static let inboxIdChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - private func inboxSend(_ obj: [String: Any]) -> [String: Any] { + static func inboxSend(_ obj: [String: Any]) -> [String: Any] { let toId = (obj["to_id"] as? String) ?? "" let text = (obj["text"] as? String) ?? "" let from = ((obj["from"] as? String) ?? "").trimmingCharacters(in: .whitespaces) @@ -859,7 +869,7 @@ final class LedgerHTTPServer { // One line out, one JSON line back; anything but {"ok":true} within the // window is a miss and the caller falls through to the file. - private func inboxSocketDeliver(path: String, line: String) -> Bool { + private static func inboxSocketDeliver(path: String, line: String) -> Bool { guard FileManager.default.fileExists(atPath: path) else { return false } let s = socket(AF_UNIX, SOCK_STREAM, 0) guard s >= 0 else { return false } diff --git a/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift index 3b0939c72f..9f0dc84c37 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift @@ -1,17 +1,13 @@ import Foundation -/// Saved Loopboy routing and the live session identity are deliberately -/// separate. A route is operational only when both agree: editing the JSON -/// registry alone never badges a session. The marker's `loopboy_contact` -/// is set at process launch (SLAB_LOOPBOY_CONTACT) or stamped in place by -/// `prox_bind_notification adopt=true`, which the prompt hook then preserves. +/// Routes must agree with the session's mutable mode (or a legacy marker). +/// A persisted OFF mode overrides old launch environments and marker writers. struct LoopboyRoute { let contact: String let channel: String let sessionId: String let host: String let name: String - let wake: Bool } enum LoopboyRoutes { @@ -34,17 +30,28 @@ enum LoopboyRoutes { channel: channel, sessionId: sid, host: (loop["host"] as? String) ?? "?", - name: (loop["name"] as? String) ?? "?", - wake: (loop["wake"] as? Bool) ?? false) + name: (loop["name"] as? String) ?? "?") } return routes } - /// Return the contact only when the saved route and immutable launch-time - /// marker agree on this exact session. + static func mode(for sessionId: String) -> [String: Any]? { + guard sessionId.range(of: "^[A-Za-z0-9._-]{1,180}$", options: .regularExpression) != nil, + sessionId != ".", sessionId != ".." else { return ["contact": ""] } + let path = "\(Paths.slabHome)/state/loopboy-modes/\(sessionId).json" + guard FileManager.default.fileExists(atPath: path) else { return nil } + guard let data = FileManager.default.contents(atPath: path), + let mode = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (mode["sessionId"] as? String) == sessionId, + mode["contact"] is String else { return ["contact": ""] } + return mode + } + + /// No launch-time contact or provider restart is required for adoption. static func verifiedContact(for session: ClaudeSession, routes: [String: LoopboyRoute]? = nil) -> String? { - let contact = session.loopboyContact.trimmingCharacters(in: .whitespacesAndNewlines) + let contact = ((mode(for: session.sessionId)?["contact"] as? String) + ?? session.loopboyContact).trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !contact.isEmpty, let route = (routes ?? all())[contact], diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index c2a55d5fb9..40d270e617 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -195,7 +195,7 @@ enum MenuBuilder { let activeContacts = Set(verified.values) for key in routes.keys.sorted() where activeContacts.contains(key) { guard let route = routes[key] else { continue } - sub.addItem(info("\(route.wake ? "↻" : "β—Œ") \(key) β†’ \(route.host):\(route.name)")) + sub.addItem(info("\(key) β†’ \(route.host):\(route.name)")) } if activeContacts.isEmpty { sub.addItem(info("No active client loops")) } let inactive = routes.keys.filter { !activeContacts.contains($0) }.sorted() diff --git a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift index 283ed71baa..4441446c0d 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/SigilRenderer.swift @@ -59,6 +59,9 @@ enum SigilRenderer { /// wrapper is reopened around the same provider thread. Ordinary prompts /// still derive their name from the ephemeral Slab session id. static func name(for session: ClaudeSession) -> String { + if let name = LoopboyRoutes.mode(for: session.sessionId)?["name"] as? String, + !name.isEmpty { return name } + if let data = FileManager.default.contents(atPath: Paths.loopboyConfig), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let loops = obj["loops"] as? [String: Any] { diff --git a/slab/menubar-swift/tests/loopboy-mode.sh b/slab/menubar-swift/tests/loopboy-mode.sh new file mode 100755 index 0000000000..18b1d10cfa --- /dev/null +++ b/slab/menubar-swift/tests/loopboy-mode.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +probeDir=$(mktemp -d /tmp/slab-loopboy-mode.XXXXXX) +trap 'rm -rf "$probeDir"' EXIT +cat "$root/Sources/SlabMenubar/LoopboyRoutes.swift" > "$probeDir/check.swift" +cat >> "$probeDir/check.swift" <<'SWIFT' +enum Paths { + static let slabHome = CommandLine.arguments[1] + static var loopboyConfig: String { "\(slabHome)/loopboy.json" } +} +struct ClaudeSession { let sessionId: String; let loopboyContact: String } +let id = "cccccccc-1111-2222-3333-444444444444" +let session = ClaudeSession(sessionId: id, loopboyContact: "fia") +func write(_ obj: [String: Any], _ path: String) { + try! JSONSerialization.data(withJSONObject: obj).write(to: URL(fileURLWithPath: path)) +} +write(["loops": ["fia": ["sessionId": id, "wake": true, "autoRespond": true]]], Paths.loopboyConfig) +assert(LoopboyRoutes.verifiedContact(for: session) == "fia") +let modes = "\(Paths.slabHome)/state/loopboy-modes" +try! FileManager.default.createDirectory(atPath: modes, withIntermediateDirectories: true) +let modeFile = "\(modes)/\(id).json" +// OFF defeats a stale marker AND a stale route carrying both legacy flags. +write(["sessionId": id, "contact": "", "name": "koker"], modeFile) +assert(LoopboyRoutes.verifiedContact(for: session) == nil) +assert(LoopboyRoutes.mode(for: id)?["name"] as? String == "koker") +// In-place adoption needs neither a launched contact nor a new session. +write(["sessionId": id, "contact": "fia"], modeFile) +assert(LoopboyRoutes.verifiedContact(for: ClaudeSession(sessionId: id, loopboyContact: "")) == "fia") +write(["loops": ["fia": ["sessionId": "different-session"]]], Paths.loopboyConfig) +assert(LoopboyRoutes.verifiedContact(for: session) == nil) +write(["loops": ["fia": ["sessionId": id, "channel": "signal"]]], Paths.loopboyConfig) +assert(LoopboyRoutes.verifiedContact(for: session) == nil) +write(["loops": ["fia": ["sessionId": id]]], Paths.loopboyConfig) +try! Data("broken".utf8).write(to: URL(fileURLWithPath: modeFile)) +assert(LoopboyRoutes.verifiedContact(for: session) == nil) +print("Loopboy mode: in-place adoption, durable exit, stale flags, route identity and channel checks passed") +SWIFT +swift "$probeDir/check.swift" "$probeDir" +# The event handler must not regain terminal control or retry wake machinery. +python3 - "$root/Sources/SlabMenubar/AppDelegate.swift" <<'PY' +import pathlib,sys +s=pathlib.Path(sys.argv[1]).read_text() +handler=s.split('private func bumpBoundProx(',1)[1].split('private func ttyForSession',1)[0] +assert 'queueInbox(' in handler and 'pokeLocal(' in handler +for forbidden in ['wakeTerminal(', 'wakeEasel(', 'typePromptWithCGEvents(', 'focusTerminal(', 'flyPrompt(', 'asyncAfter', 'screen']: + assert forbidden not in handler, forbidden +assert 'loopboyWakeInFlight' not in s +assert 'loopboyEvaluatedFingerprint' not in s +print('Loopboy arrivals cannot enter the terminal wake path; heartbeat retry loop removed') +PY diff --git a/slab/test/loopboy-mode.test.mjs b/slab/test/loopboy-mode.test.mjs new file mode 100644 index 0000000000..beb9624f8d --- /dev/null +++ b/slab/test/loopboy-mode.test.mjs @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readLoopboyMode } from '../lib/loopboy-mode.mjs'; + +const script = new URL('../bin/prox-mcp.mjs', import.meta.url).pathname; +async function fixture(t, agent = 'codex') { + const home = await mkdtemp(join(tmpdir(), 'loopboy-mode-')); + t.after(() => rm(home, { recursive: true, force: true })); + const env = { ...process.env, HOME: home, SLAB_HOME: join(home, '.local/share/slab') }; + const sid = 'cccccccc-1111-2222-3333-444444444444'; + const slab = join(home, '.config/slab'); + const active = join(env.SLAB_HOME, 'state/active-prompts'); + await mkdir(join(slab, 'ledger'), { recursive: true }); + await mkdir(active, { recursive: true }); + const marker = { session_id: sid, agent_pid: process.pid, claude_pid: process.pid, + agent_type: agent, provider_session_id: 'unchanged-provider-thread', tty: 'ttys006', + cwd: home, subject: 'keep the history', state: 'working', loopboy_contact: '' }; + await writeFile(join(active, sid), JSON.stringify(marker)); + const entry = { id: sid, name: 'koker', host: 'test', kind: 'session', agentType: agent, + status: 'working', updated: Date.now(), cwd: home }; + await writeFile(join(slab, 'ledger/local.json'), JSON.stringify({ host: 'test', entries: [entry] })); + async function call(name, args, extraEnv = {}) { + const child = spawn(process.execPath, [script], { env: { ...env, ...extraEnv }, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', chunk => output += chunk); + child.stdin.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }) + '\n'); + const [code] = await once(child, 'close'); + assert.equal(code, 0); + return JSON.parse(output).result; + } + return { home, env, sid, slab, active, marker, entry, call, + config: async () => JSON.parse(await readFile(join(slab, 'loopboy.json'), 'utf8')), + current: async () => JSON.parse(await readFile(join(active, sid), 'utf8')) }; +} + +for (const agent of ['claude', 'codex', 'aesel']) { + test(`${agent} enters, exits, and reenters Loopboy without a new process or conversation`, async t => { + const f = await fixture(t, agent); + for (let pass = 0; pass < 2; pass++) { + const on = await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia', wake: true }); + assert.equal(on.isError, undefined, on.content[0].text); + assert.match(on.content[0].text, /in place.*no automatic typing/); + const cfg = await f.config(); + assert.equal(cfg.loops.fia.wake, false); + assert.equal(cfg.loops.fia.delivery, 'inbox'); + assert.equal(cfg.loops.fia.sessionId, f.sid); + assert.equal((await f.current()).loopboy_contact, 'fia'); + const off = await f.call('prox_unbind_notification', { handle: 'test:koker' }, { SLAB_LOOPBOY_CONTACT: 'fia' }); + assert.equal(off.isError, undefined, off.content[0].text); + assert.deepEqual((await f.config()).loops, {}); + assert.equal((await readLoopboyMode(f.sid, f.env)).contact, ''); + const current = await f.current(); + for (const key of ['session_id', 'agent_pid', 'provider_session_id', 'tty', 'subject', 'state']) assert.equal(current[key], f.marker[key]); + // Simulate the still-running old watcher writing its cached launch contact. + await writeFile(join(f.active, f.sid), JSON.stringify({ ...current, loopboy_contact: 'fia' })); + const rebind = await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'alex' }); + assert.equal(rebind.isError, undefined, rebind.content[0].text); + assert.equal((await f.config()).loops.alex.sessionId, f.sid); + await f.call('prox_unbind_notification', { handle: 'test:koker' }); + } + assert.deepEqual((await f.config()).loops, {}); + }); +} + +test('live route ownership cannot be stolen; unrelated routes survive mode changes', async t => { + const f = await fixture(t); + const other = 'dddddddd-1111-2222-3333-444444444444'; + await writeFile(join(f.active, other), JSON.stringify({ session_id: other, agent_pid: process.pid })); + const loops = { fia: { sessionId: other, wake: false }, alex: { sessionId: 'unrelated' } }; + await writeFile(join(f.slab, 'loopboy.json'), JSON.stringify({ loops })); + const refused = await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia' }); + assert.equal(refused.isError, true); + assert.match(refused.content[0].text, /already has a live listener/); + assert.deepEqual((await f.config()).loops, loops); + assert.equal((await f.current()).loopboy_contact, ''); + await f.call('prox_unbind_notification', { handle: 'test:koker' }); + assert.deepEqual((await f.config()).loops, loops); +}); + +test('mode changes refuse dead or ambiguous sessions without writing configuration', async t => { + const f = await fixture(t); + await writeFile(join(f.active, f.sid), JSON.stringify({ ...f.marker, agent_pid: 2147483647, claude_pid: 0 })); + const dead = await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia' }); + assert.equal(dead.isError, true); + assert.match(dead.content[0].text, /dead process/); + await writeFile(join(f.slab, 'ledger/local.json'), JSON.stringify({ host: 'test', entries: [f.entry, { ...f.entry, id: 'another' }] })); + const ambiguous = await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia' }); + assert.equal(ambiguous.isError, true); + assert.match(ambiguous.content[0].text, /ambiguous/); + assert.equal(await readLoopboyMode(f.sid, f.env), null); +}); + +test('exit preserves an explicitly assigned name despite a stale ledger', async t => { + const f = await fixture(t); + await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia', name: 'surizo' }); + const off = await f.call('prox_unbind_notification', { handle: 'test:surizo' }); + assert.equal(off.isError, undefined); + assert.match(off.content[0].text, /test:surizo/); + assert.equal((await readLoopboyMode(f.sid, f.env)).name, 'surizo'); +}); + +test('launch refuses to replace a contact’s existing live prox before opening anything', async t => { + const f = await fixture(t); + await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia' }); + const result = await f.call('prox_launch', { host: 'test', agent: 'codex', loopboyContact: 'fia' }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /already has a live prox.*without launching a replacement/); + assert.equal((await f.config()).loops.fia.sessionId, f.sid); + const off = await f.call('prox_unbind_notification', { handle: 'test:koker' }); + assert.equal(off.isError, undefined); + assert.equal((await f.call('prox_unbind_notification', { handle: 'test:koker' })).isError, undefined); +}); + +test('passive wait delivers through the existing session without launch contact headers', async t => { + const f = await fixture(t); + await f.call('prox_bind_notification', { handle: 'test:koker', contact: 'fia' }); + const identity = { SLAB_PROMPT_SESSION_ID: f.sid, SLAB_LOOPBOY_CONTACT: '' }; + const inbox = join(f.env.SLAB_HOME, 'inbox', f.sid); + await mkdir(inbox, { recursive: true }); + await writeFile(join(inbox, 'messages.jsonl'), JSON.stringify({ v: 1, id: 'arrival', ts: Date.now(), + from: 'loopboy:fia', to_id: f.sid, text: 'A new message is available.', urgency: 'queue', kind: 'message' }) + '\n'); + const foreign = await f.call('prox_loopboy_wait', { contact: 'alex', timeoutSeconds: 0 }, identity); + assert.equal(foreign.isError, true); + const arrival = await f.call('prox_loopboy_wait', { timeoutSeconds: 0 }, identity); + assert.equal(arrival.isError, undefined); + assert.match(arrival.content[0].text, /A new message is available/); + const empty = await f.call('prox_loopboy_wait', { timeoutSeconds: 0 }, identity); + assert.match(empty.content[0].text, /No queued updates for fia/); + // Exit revokes a currently waiting tool call, even with stale launch headers. + const pending = f.call('prox_loopboy_wait', { timeoutSeconds: 5 }, { ...identity, SLAB_LOOPBOY_CONTACT: 'fia' }); + await new Promise(resolve => setTimeout(resolve, 150)); + await f.call('prox_unbind_notification', { handle: 'test:koker' }); + const stopped = await pending; + assert.equal(stopped.isError, true); + assert.match(stopped.content[0].text, /mode is off/); + assert.equal((await f.current()).provider_session_id, f.marker.provider_session_id); +}); -- 2.51.2