From 5d60e965d28db770baded2d42ec3b8d89045a8f3 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 23 Jul 2026 00:20:40 +0000 Subject: [PATCH] Replace Loopboy UI wake with private inbox --- slab/bin/codex-slab | 1 + slab/bin/imsg.mjs | 24 +++++++++++++++++++----- slab/bin/prox-mcp.mjs | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- slab/lib/imessage-rich-text.mjs | 39 +++++++++++++++++++++++++++++++++++++++ slab/lib/loopboy-inbox.mjs | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ slab/menubar-swift/README.md | 4 +++- slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------- slab/test/imessage-rich-text.test.mjs | 19 +++++++++++++++++++ slab/test/loopboy-inbox.test.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 9 file(s) changed, 371 insertion(s)(+), 49 deletion(s)(-) diff --git a/slab/bin/codex-slab b/slab/bin/codex-slab --- a/slab/bin/codex-slab +++ b/slab/bin/codex-slab @@ -33,6 +33,7 @@ exec codex "$@" fi sid=$(uuidgen) +export SLAB_PROMPT_SESSION_ID="$sid" begin=$(date +%s) # The wrapper (this bash process) is the parent of codex and holds the tab's # controlling tty — use it for liveness and graceful prompt parking. diff --git a/slab/bin/imsg.mjs b/slab/bin/imsg.mjs --- a/slab/bin/imsg.mjs +++ b/slab/bin/imsg.mjs @@ -20,7 +20,8 @@ // imsg read [N] recent messages; optional --to // imsg search full-text search; optional --to and --limit // imsg index incrementally refresh the private local FTS index // imsg use switch the notification/default contact safely -// imsg send send to an explicitly selected contact via Messages.app +// imsg send send to an explicitly selected contact via Messages.app; +// --rich renders lightweight Markdown as safe styled text // imsg react classic Tapback on that contact's latest incoming message // imsg tail live terminal client (prints + BEL on new inbound) // imsg open open the Messages.app conversation @@ -37,6 +38,7 @@ writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; +import { formatRichText } from "../lib/imessage-rich-text.mjs"; const HOME = homedir(); const CONFIG_PATH = @@ -1009,11 +1011,23 @@ } // Optional `--to ` selects a contact; default otherwise. const args = [...rest]; let toArg = null; + const richIndex = args.indexOf("--rich"); + const rich = richIndex >= 0; + if (rich) args.splice(richIndex, 1); const ti = args.indexOf("--to"); - if (ti >= 0) { toArg = args[ti + 1]; args.splice(ti, 2); } - const body = args.join(" ").trim(); + if (ti >= 0) { + const candidate = args[ti + 1]; + if (!candidate || candidate.startsWith("--")) { + console.error("imsg send: --to requires a nonempty contact name or handle"); + process.exit(1); + } + toArg = candidate; + args.splice(ti, 2); + } + const source = args.join(" ").trim(); + const body = rich ? formatRichText(source) : source; if (!body) { - console.error("usage: imsg send [--to ]"); + console.error("usage: imsg send [--rich] [--to ]"); process.exit(1); } const rcpt = resolveRecipient(cfg, toArg); @@ -1064,7 +1078,7 @@ process.stdout.write(CONFIG_PATH + "\n"); break; default: console.error( - "usage: imsg status|chats [N]|read [N] [--to ]|search [--to ] [--limit N]|index [--all] [--rebuild]|use |ack|resolve [--to] |send [--to ]|react --to |tail|open|config", + "usage: imsg status|chats [N]|read [N] [--to ]|search [--to ] [--limit N]|index [--all] [--rebuild]|use |ack|resolve [--to] |send [--rich] [--to ]|react --to |tail|open|config", ); process.exit(1); } diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -27,6 +27,7 @@ import { join } from "node:path"; import { homedir, hostname } from "node:os"; import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs"; import { boundedNudge, makeIrisContact, parseAgentAddress } from "../lib/loopboy-family.mjs"; +import { enqueueLoopboyEvent, waitLoopboyEvent } from "../lib/loopboy-inbox.mjs"; const pexec = promisify(execFile); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -325,6 +326,21 @@ if (!hits.length) throw new Error(`no rock resolves «${handle}» to wake.`); if (hits.length > 1) throw new Error(`«${handle}» is ambiguous (${hits.map((r) => `${r.host}:${r.name}`).join(", ")}).`); const r = hits[0]; if (!r.self) throw new Error(`${r.host}:${r.name} runs on another machine — prox_wake currently requires the MCP on the rock's owning host.`); + + // Loopboys never accept Terminal/UI injection. Their own long-polling MCP + // call consumes this session-addressed event without touching user focus. + if (r.loopboyContact) { + await enqueueLoopboyEvent({ + sessionId: r.id, + contact: r.loopboyContact, + displayName: r.loopboyContact, + kind: "message", + prompt: steering, + excerpt: String(by || "prox-wake"), + }); + return [{ type: "text", text: `queued ${r.host}:${r.name} in its isolated Loopboy inbox; no Terminal or GUI input was emitted.` }]; + } + if (!r.ip) throw new Error(`no tailnet ip known for ${r.host} — can't reach its menubar wake path.`); // Route through the menubar so prox and Loopboy share one re-entry UX: @@ -382,6 +398,13 @@ if (!target) throw new Error(`no cached ledger for host «${host}» — it must be online in prox first.`); if (!target.ip) throw new Error(`no tailnet IP known for ${target.host}.`); const self = (await readJson(LOCAL_FILE))?.host || hostname().split(".")[0]; const contactKey = String(loopboyContact || "").trim().toLowerCase(); + const loopboyListenerPrompt = contactKey + ? `\n\nLoopboy listener contract: after completing this turn, call prox_loopboy_wait with contact ${contactKey} and timeoutSeconds 50. Call it again after every event or timeout so this client loop remains available. Events arrive only through that private MCP inbox. Never use Terminal, clipboard, mouse, keyboard, browser, or GUI injection to wake or operate this loop.` + : ""; + const launchPrompt = `${String(prompt)}${loopboyListenerPrompt}`.trim(); + if (launchPrompt.length > 4000) { + throw new Error("Loopboy listener instructions make the initial prompt exceed 4000 characters."); + } const existingMarkerIds = new Set(); if (contactKey) { for (const dir of MARKER_DIRS) { @@ -393,7 +416,7 @@ } const launcher = by || `${self}:prox`; const body = JSON.stringify({ agent: agentName, - prompt: String(prompt), + prompt: launchPrompt, ...(cwd ? { cwd: String(cwd) } : {}), ...(contactKey ? { loopboyContact: contactKey } : {}), by: launcher, @@ -441,7 +464,8 @@ contact: contactKey, sessionId: marker.id, host: result.host || target.host, agent: agentName, - wake: true, + wake: false, + delivery: "inbox", assignedAt: new Date().toISOString(), }; await writeFile(LOOPBOY_CONFIG, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); @@ -453,7 +477,7 @@ text: `launched ${agentName} on ${result.host || target.host} in ${result.cwd} as «${launcher}»${prompt ? " with an initial prompt" : ""}${binding}.`, }]; } -async function toolBindNotification({ handle, contact, event = "imessage", wake = true }) { +async function toolBindNotification({ handle, contact, event = "imessage" }) { if (event !== "imessage") throw new Error("only the `imessage` Slab notification is supported"); if (!handle) throw new Error("`handle` is required (use the stable host:name or session id)"); const contactKey = String(contact || "").trim().toLowerCase(); @@ -469,7 +493,8 @@ contact: contactKey, sessionId: r.id, host: r.host, name: r.name, - wake: wake !== false, + wake: false, + delivery: "inbox", assignedAt: new Date().toISOString(), }; await mkdir(join(homedir(), ".config", "slab"), { recursive: true }); @@ -478,7 +503,49 @@ cfg.version = 1; cfg.loops ||= {}; cfg.loops[contactKey] = loop; await writeFile(LOOPBOY_CONFIG, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); - return [{ type: "text", text: `Loopboy bound ${contactKey} → ${r.host}:${r.name} (${r.id}) — poke${loop.wake ? " + reactivate" : " only"}.` }]; + return [{ type: "text", text: `Loopboy bound ${contactKey} → ${r.host}:${r.name} (${r.id}) — isolated inbox delivery; no Terminal/UI injection.` }]; +} + +async function toolLoopboyWait({ handle, contact, timeoutSeconds = 50 }) { + const cfg = await readJson(LOOPBOY_CONFIG); + const loops = cfg?.loops || {}; + const boundContact = String(process.env.SLAB_LOOPBOY_CONTACT || "").trim().toLowerCase(); + if (!boundContact) { + throw new Error("prox_loopboy_wait is available only inside a SLAB_LOOPBOY_CONTACT session"); + } + const requestedContact = String(contact || "").trim().toLowerCase(); + if (requestedContact && requestedContact !== boundContact) { + throw new Error(`this Loopboy is bound to ${boundContact}, not ${requestedContact}`); + } + let contactKey = boundContact; + let loop = contactKey ? loops[contactKey] : null; + if (!loop && handle) { + const hits = resolve(await allRocks(), handle); + if (!hits.length) throw new Error(`no rock resolves «${handle}» for Loopboy wait.`); + if (hits.length > 1) throw new Error(`«${handle}» is ambiguous.`); + const rock = hits[0]; + const found = Object.entries(loops).find(([, value]) => value?.sessionId === rock.id); + if (found) [contactKey, loop] = found; + } + if (!loop?.sessionId) { + throw new Error("Loopboy wait requires a bound `contact` or Loopboy `handle`."); + } + const seconds = Math.max(0, Math.min(55, Number(timeoutSeconds) || 0)); + const event = await waitLoopboyEvent(loop.sessionId, { timeoutMs: seconds * 1000 }); + if (!event) { + return [{ + type: "text", + text: `No event arrived for Loopboy ${contactKey} during this wait. Call prox_loopboy_wait again; do not poll Messages through GUI automation.`, + }]; + } + return [{ + type: "text", + text: [ + `Loopboy inbox event for ${contactKey} (${event.kind}, ${event.createdAt}).`, + event.prompt, + "After handling this event, call prox_loopboy_wait again to remain available. Never use Terminal, clipboard, mouse, keyboard, browser, or GUI injection.", + ].filter(Boolean).join("\n\n"), + }]; } async function toolClose({ handle }) { @@ -625,7 +692,7 @@ agent: { type: "string", enum: ["claude", "codex"], description: "Agent CLI to launch." }, cwd: { type: "string", description: "Optional absolute directory on the target. Defaults to its aesthetic-computer checkout and must stay under its home folder." }, prompt: { type: "string", description: "Optional initial prompt, at most 4000 characters. Omit to open an idle TUI." }, by: { type: "string", description: "Optional caller label recorded by the target." }, - loopboyContact: { type: "string", description: "Optional iMessage contact key. Launches a direct-terminal Loopboy and binds it after its live marker appears." }, + loopboyContact: { type: "string", description: "Optional iMessage contact key. Launches a guarded Loopboy, binds its isolated inbox after the live marker appears, and starts its listener contract." }, }, required: ["host", "agent"], }, @@ -633,16 +700,28 @@ }, { name: "prox_bind_notification", description: - "Create or replace one contact-keyed Loopboy route from iMessage to a stable local prox. Every new inbound from that contact pokes the rock and, by default, reactivates its terminal session with a steering prompt. This does not send or react to the incoming message.", + "Create or replace one contact-keyed Loopboy route from iMessage to a stable local prox. Events are delivered only through that session's isolated inbox and prox_loopboy_wait; this never types into Terminal or other user-space UI.", inputSchema: { type: "object", properties: { handle: { type: "string", description: "Stable local host:name, session id, or an unambiguous subject fragment." }, contact: { type: "string", description: "Contact key from ~/.config/slab/imsg.json, for example alex." }, event: { type: "string", enum: ["imessage"], default: "imessage" }, - wake: { type: "boolean", default: true, description: "Also reactivate the agent session; false means visual poke only." }, }, required: ["handle", "contact"], + }, + }, + { + name: "prox_loopboy_wait", + description: + "Wait up to 55 seconds for the next event in one bound Loopboy session's isolated inbox. The event is claimed exactly once and returned only to that session/contact. This is the safe replacement for Terminal, clipboard, mouse, and keyboard wake injection. Call it again after handling each event.", + inputSchema: { + type: "object", + properties: { + handle: { type: "string", description: "Bound Loopboy host:name or session id. Either handle or contact is required." }, + contact: { type: "string", description: "Bound iMessage contact key, for example alex or loretta." }, + timeoutSeconds: { type: "number", minimum: 0, maximum: 55, default: 50 }, + }, }, }, { @@ -680,6 +759,7 @@ case "prox_wake": return toolWake(args || {}); case "prox_artifact_ready": return toolArtifactReady(args || {}); case "prox_launch": return toolLaunch(args || {}); case "prox_bind_notification": return toolBindNotification(args || {}); + case "prox_loopboy_wait": return toolLoopboyWait(args || {}); case "prox_loopboy_agent_status": return toolLoopboyAgentStatus(args || {}); case "prox_loopboy_agent_nudge": return toolLoopboyAgentNudge(args || {}); case "prox_close": return toolClose(args || {}); diff --git a/slab/lib/imessage-rich-text.mjs b/slab/lib/imessage-rich-text.mjs new file mode 100644 --- /dev/null +++ b/slab/lib/imessage-rich-text.mjs @@ -0,0 +1,39 @@ +const BOLD_UPPER = 0x1d400; +const BOLD_LOWER = 0x1d41a; +const BOLD_DIGIT = 0x1d7ce; +const ITALIC_UPPER = 0x1d434; +const ITALIC_LOWER = 0x1d44e; + +function mapMath(text, style) { + return Array.from(text, (character) => { + const code = character.codePointAt(0); + if (code >= 65 && code <= 90) { + return String.fromCodePoint((style === "bold" ? BOLD_UPPER : ITALIC_UPPER) + code - 65); + } + if (code >= 97 && code <= 122) { + if (style === "italic" && character === "h") return "ℎ"; + return String.fromCodePoint((style === "bold" ? BOLD_LOWER : ITALIC_LOWER) + code - 97); + } + if (style === "bold" && code >= 48 && code <= 57) { + return String.fromCodePoint(BOLD_DIGIT + code - 48); + } + return character; + }).join(""); +} + +function strike(text) { + return Array.from(text, (character) => /\s/u.test(character) ? character : `${character}\u0336`).join(""); +} + +export function formatRichText(input) { + let text = String(input || "").replace(/\r\n?/g, "\n").trim(); + text = text.replace(/^#{1,3}\s+(.+)$/gm, (_, title) => mapMath(title, "bold")); + text = text.replace(/^\s*[-*]\s+/gm, "• "); + text = text.replace(/\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g, "$1 — $2"); + text = text.replace(/\*\*([^*\n]+)\*\*/g, (_, value) => mapMath(value, "bold")); + text = text.replace(/__([^_\n]+)__/g, (_, value) => mapMath(value, "bold")); + text = text.replace(/~~([^~\n]+)~~/g, (_, value) => strike(value)); + text = text.replace(/(? mapMath(value, "italic")); + text = text.replace(/(? mapMath(value, "italic")); + return text; +} diff --git a/slab/lib/loopboy-inbox.mjs b/slab/lib/loopboy-inbox.mjs new file mode 100644 --- /dev/null +++ b/slab/lib/loopboy-inbox.mjs @@ -0,0 +1,100 @@ +import { chmod, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const LOOPBOY_INBOX_ROOT = process.env.SLAB_LOOPBOY_INBOX || join( + process.env.SLAB_HOME || join(homedir(), ".local", "share", "slab"), + "loopboy", + "inbox", +); + +function safeSessionId(value) { + const sessionId = String(value || "").trim(); + if (!/^[A-Za-z0-9-]{8,128}$/.test(sessionId)) { + throw new Error("Loopboy inbox requires a valid session id"); + } + return sessionId; +} + +function sessionDir(sessionId) { + return join(LOOPBOY_INBOX_ROOT, safeSessionId(sessionId)); +} + +export async function enqueueLoopboyEvent(event) { + const sessionId = safeSessionId(event?.sessionId); + const dir = sessionDir(sessionId); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmod(dir, 0o700); + const createdAt = event.createdAt || new Date().toISOString(); + const id = event.id || randomUUID(); + const payload = { + version: 1, + id, + sessionId, + contact: String(event.contact || ""), + displayName: String(event.displayName || event.contact || ""), + kind: event.kind === "heartbeat" ? "heartbeat" : "message", + fromMe: event.fromMe === true, + excerpt: String(event.excerpt || "").slice(0, 500), + prompt: String(event.prompt || "").slice(0, 6000), + createdAt, + }; + const stamp = Date.now().toString().padStart(13, "0"); + const name = `${stamp}-${payload.kind}-${id}.json`; + if (payload.kind === "heartbeat") { + const oldHeartbeats = (await readdir(dir)).filter((entry) => + /^\d{13}-heartbeat-.*\.json$/.test(entry) + ); + await Promise.all(oldHeartbeats.map((entry) => unlink(join(dir, entry)).catch(() => {}))); + } + const temp = join(dir, `.${name}.${process.pid}.tmp`); + const final = join(dir, name); + await writeFile(temp, `${JSON.stringify(payload)}\n`, { mode: 0o600 }); + await rename(temp, final); + return payload; +} + +async function claimOldest(sessionId) { + const dir = sessionDir(sessionId); + let names = []; + try { + names = (await readdir(dir)).filter((name) => /^\d{13}-(?:heartbeat|message)-.*\.json$/.test(name)).sort(); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } + for (const name of names) { + const source = join(dir, name); + const claimed = join(dir, `.claimed-${process.pid}-${randomUUID()}.json`); + try { + await rename(source, claimed); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + try { + const event = JSON.parse(await readFile(claimed, "utf8")); + if (event.sessionId !== safeSessionId(sessionId)) { + throw new Error("Loopboy inbox event/session mismatch"); + } + return event; + } finally { + await unlink(claimed).catch(() => {}); + } + } + return null; +} + +export async function waitLoopboyEvent(sessionId, { timeoutMs = 50_000, pollMs = 200 } = {}) { + const sid = safeSessionId(sessionId); + const deadline = Date.now() + Math.max(0, Math.min(55_000, Number(timeoutMs) || 0)); + do { + const event = await claimOldest(sid); + if (event) return event; + if (Date.now() >= deadline) return null; + await sleep(Math.max(50, Math.min(1000, Number(pollMs) || 200))); + } while (true); +} diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -101,7 +101,9 @@ Loopboy is Slab's client-loop router. Routes in `~/.config/slab/loopboy.json` map one private iMessage contact key to one local prox session. New inbound messages poke and optionally wake only that contact's -rock; Loopboy never replies on its own. Armed Loopboy rocks spin faster, wear a +rock. Heartbeats and messages are written to a private, per-session inbox and +consumed by `prox_loopboy_wait`; they never type into Terminal, use the +clipboard, or move focus. Loopboy never replies on its own. Armed Loopboy rocks spin faster, wear a pink glow, and identify themselves in their hover bubble. The Slab menu lists all active client loops and their prox targets. 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 @@ -104,7 +104,6 @@ /// 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() /// Generic prox bumps use the same re-entry primitive and overlap guard as /// Loopboy, keyed by stable session id instead of contact. private var proxWakeInFlight = Set() @@ -845,10 +844,9 @@ state.messageWaiting = state.inputNotificationCount > 0 || 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. + /// Poke the prox explicitly assigned to iMessage awareness and enqueue a + /// session-addressed event for its long-polling MCP listener. Loopboy must + /// never type into Terminal, touch the clipboard, or use foreground UI. private func bumpBoundProx(contact: String, displayLabel: String, message: String, fromMe: Bool = false, heartbeat: Bool = false) { guard let data = FileManager.default.contents(atPath: Paths.loopboyConfig), @@ -865,10 +863,8 @@ NSLog("💬 [loopboy] %@ route host %@ is not local %@; refusing wake", contact, routeHost, localHost) 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, let tty = ttyForSession(sid), !tty.isEmpty else { return } let clean = message.replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -876,7 +872,7 @@ let excerpt = String(clean.prefix(240)) let direction = fromMe ? "outgoing to" : "incoming from" let boundSession = state.claudeSessions.first(where: { $0.sessionId == sid }) let responsePolicy = autoRespond - ? " 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." + ? " This route explicitly authorizes automatic responses: after completing and validating any work, reread the newest thread context, discard stale drafts, compose concise Markdown with paragraphs, bullets, links, and restrained emphasis, then send one appropriate reply using `node slab/bin/imsg.mjs send --rich --to \(contact)`. Verify it appears outbound. Never duplicate a response." : " Do not send or react automatically." let taskPrompt: String if heartbeat { @@ -899,36 +895,58 @@ taskPrompt = update + responsePolicy } let localOnly = " Execution boundary: remain on this local Neo host and use only non-interactive shell, repository, HTTP, and service API operations. Do not use browser automation, Puppet, Frame, CDP, GUI apps, mouse or keyboard control, open/reveal commands, SSH, or any other machine—including Panda or Blueberry—unless Jeffrey explicitly requests that exact interactive action in the current thread. If validation would require GUI control, report the blocker locally instead." let prompt = taskPrompt + localOnly - let providerId = boundSession?.providerSessionId ?? "" - let nudgeScreen = boundSession?.nudgeScreen ?? "" - let sessionCwd = boundSession?.cwd ?? Paths.acRepo - let agentType = boundSession?.agentType ?? "claude" - 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 || status == 4 { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.bumpBoundProx(contact: contact, - displayLabel: displayLabel, - message: message, - fromMe: fromMe, - heartbeat: heartbeat) - } + let queued = enqueueLoopboyEvent( + sessionId: sid, contact: contact, displayName: displayLabel, + kind: heartbeat ? "heartbeat" : "message", fromMe: fromMe, + excerpt: excerpt, prompt: prompt) + NSLog("💬 [loopboy] %@ event %@ inbox=%@ prox=%@", + contact, heartbeat ? "heartbeat" : "message", + queued ? "queued" : "failed", String(sid.prefix(8))) + } + + private func enqueueLoopboyEvent(sessionId: String, contact: String, + displayName: String, kind: String, + fromMe: Bool, excerpt: String, + prompt: String) -> Bool { + guard sessionId.range(of: "^[A-Za-z0-9-]{8,128}$", + options: .regularExpression) != nil else { return false } + let dir = "\(Paths.slabHome)/loopboy/inbox/\(sessionId)" + do { + try FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let id = UUID().uuidString + let event: [String: Any] = [ + "version": 1, + "id": id, + "sessionId": sessionId, + "contact": contact, + "displayName": displayName, + "kind": kind, + "fromMe": fromMe, + "excerpt": String(excerpt.prefix(500)), + "prompt": String(prompt.prefix(6000)), + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + let data = try JSONSerialization.data(withJSONObject: event) + let stamp = String(Int(Date().timeIntervalSince1970 * 1000)) + if kind == "heartbeat", let names = try? FileManager.default.contentsOfDirectory(atPath: dir) { + for name in names where name.range( + of: "^[0-9]{13}-heartbeat-.*\\.json$", + options: .regularExpression) != nil { + try? FileManager.default.removeItem(atPath: "\(dir)/\(name)") } } + let final = URL(fileURLWithPath: "\(dir)/\(stamp)-\(kind)-\(id).json") + try data.write(to: final, options: .atomic) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: final.path) + return true + } catch { + NSLog("💬 [loopboy] inbox write failed for %@: %@", + String(sessionId.prefix(8)), error.localizedDescription) + return false } - NSLog("💬 [loopboy] \(contact) poked + starting wake prox \(sid.prefix(8)) on \(tty)") } /// Receive one bounded prox continuation from the ledger server and route diff --git a/slab/test/imessage-rich-text.test.mjs b/slab/test/imessage-rich-text.test.mjs new file mode 100644 --- /dev/null +++ b/slab/test/imessage-rich-text.test.mjs @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { formatRichText } from "../lib/imessage-rich-text.mjs"; + +test("formats Loopboy correspondence without RTF attachments", () => { + const result = formatRichText([ + "# Update", + "**Done:** shipped the fix.", + "- *Verified* locally", + "- [Open report](https://example.com/report)", + "~~stale~~", + ].join("\n")); + assert.match(result, /𝐔𝐩𝐝𝐚𝐭𝐞/); + assert.match(result, /𝐃𝐨𝐧𝐞/); + assert.match(result, /• 𝑉𝑒𝑟𝑖𝑓𝑖𝑒𝑑 locally/); + assert.match(result, /Open report — https:\/\/example\.com\/report/); + assert.match(result, /s̶t̶a̶l̶e̶/); + assert.doesNotMatch(result, /\\rtf|\*\*|\]\(/); +}); diff --git a/slab/test/loopboy-inbox.test.mjs b/slab/test/loopboy-inbox.test.mjs new file mode 100644 --- /dev/null +++ b/slab/test/loopboy-inbox.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +test("Loopboy inbox isolates sessions and claims each event once", async () => { + const root = await mkdtemp(join(tmpdir(), "loopboy-inbox-")); + process.env.SLAB_LOOPBOY_INBOX = root; + const { enqueueLoopboyEvent, waitLoopboyEvent } = await import( + `../lib/loopboy-inbox.mjs?test=${Date.now()}` + ); + const gimipi = "41852042-FE5B-41E9-8760-D4680D73928C"; + const meloza = "3DEB321E-A29B-4C32-8DB1-9DC8F382E0F3"; + try { + await enqueueLoopboyEvent({ sessionId: gimipi, contact: "alex", kind: "heartbeat" }); + await enqueueLoopboyEvent({ sessionId: meloza, contact: "loretta", kind: "message" }); + + const melozaEvent = await waitLoopboyEvent(meloza, { timeoutMs: 0 }); + assert.equal(melozaEvent.contact, "loretta"); + assert.equal(melozaEvent.sessionId, meloza); + + const gimipiEvent = await waitLoopboyEvent(gimipi, { timeoutMs: 0 }); + assert.equal(gimipiEvent.contact, "alex"); + assert.equal(gimipiEvent.sessionId, gimipi); + + assert.equal(await waitLoopboyEvent(gimipi, { timeoutMs: 0 }), null); + assert.equal(await waitLoopboyEvent(meloza, { timeoutMs: 0 }), null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("Loopboy inbox coalesces unattended heartbeats", async () => { + const root = await mkdtemp(join(tmpdir(), "loopboy-heartbeat-")); + process.env.SLAB_LOOPBOY_INBOX = root; + const { enqueueLoopboyEvent, waitLoopboyEvent } = await import( + `../lib/loopboy-inbox.mjs?heartbeat=${Date.now()}` + ); + const sessionId = "41852042-FE5B-41E9-8760-D4680D73928C"; + try { + await enqueueLoopboyEvent({ sessionId, contact: "alex", kind: "heartbeat", excerpt: "first" }); + await enqueueLoopboyEvent({ sessionId, contact: "alex", kind: "heartbeat", excerpt: "latest" }); + assert.equal((await readdir(join(root, sessionId))).length, 1); + assert.equal((await waitLoopboyEvent(sessionId, { timeoutMs: 0 })).excerpt, "latest"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); -- tangled.sh