From d0e5faab53b6839a24042413351c5e0cda3f332d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 27 Jul 2026 16:41:38 -0700 Subject: [PATCH] Teach Loopboy self-shutdown and finish capture fixes --- SCORE.md | 4 +- captutor/lib/tutorial-layout.mjs | 64 +++++++++++++-- .../screenplays/talking-taco-continuation.mjs | 1 - slab/bin/prox-mcp.mjs | 82 +++++++++++++++++-- slab/menubar-swift/Info.plist | 2 + slab/menubar-swift/README.md | 8 ++ .../Sources/SlabMenubar/FrameCapture.swift | 24 ++++-- .../SlabMenubar/PromptSigilOverlay.swift | 11 --- slab/test/prox-mcp.test.mjs | 37 +++++++++ 9 files changed, 201 insertions(+), 32 deletions(-) diff --git a/SCORE.md b/SCORE.md index 4b3652e30..956dcc76e 100644 --- a/SCORE.md +++ b/SCORE.md @@ -194,8 +194,8 @@ This is critical because `lib/pmove.mjs` is shared physics: client (lith) and se - **Media render budget** — `~/.local/bin/{ffmpeg,ffprobe}` point at the repo QoS shims in `toolchain/shims/`, so shell/agent media work runs at utility priority and yields to the interactive UI under contention. Do not invoke the Homebrew binaries by absolute path unless deliberately bypassing QoS for a benchmark. - **Tiling auto-fits the type:** `tileNow` sizes each Terminal window's font to the grid (Far/Near/Tiny modes) and drives `View ▸ Default Font Size` per window so a live window actually adopts it — a per-window zoom otherwise silently overrides the profile font and is invisible to AppleScript. Floors keep it legible (Far 10 / Near 9 / Tiny 8). iTerm2 has no AppleScript font property, so it tiles by bounds only. - **Prompt rocks** (`slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift`) — the tumbling little stones parked at the top-right of each terminal window, one per live Claude session. Each rock is a 3D sigil rendered from the session's `sessionId + prompt` seed (so it re-forms when the session moves to a new prompt), lit by a shared global sun that tracks local time of day, wearing a pet name in bubble lettering. Its *motion* is the status channel — spin speed and direction encode working/awaiting/complete; being read by a peer makes it blink and rattle. Hovering one reveals a bubble summarizing the prompt (a cached one-line `claude -p haiku` inference). They're borderless click-through `.floating` windows, so hit-testing has to check occlusion by hand: a rock only answers the pointer while it's on screen *and* its terminal is still the topmost normal window under the cursor. -- **Prompt rocks MCP** (`slab/bin/prox-mcp.mjs`, registered as `prox` in `.mcp.json`) — an MCP over the fleet handle ledger (`Ledger.swift`; `~/.config/slab/ledger/{local,peers/*}.json`, served per-machine on tailnet-only `:5252`) so any agent can `prox_list` every live session across machines, `prox_find` a `host:name` reference (e.g. `neo:regif`) to its status/subject/cwd/seed, `prox_poke` one (`POST /poke` → the target rock blinks + rattles), `prox_wake` a local one with a bounded steering prompt, send `prox_artifact_ready` the output paths from an asynchronous render, and `prox_launch` a new Claude/Codex Terminal on a prompt host. Wake uses the same poke + TTY reactivation pattern as Loopboy; `prox_artifact_ready` supplies the standard inspect/iterate/integrate/continue prompt itself. Launch is deliberately not a remote shell: the target accepts only those two fixed agents, a bounded prompt, and a cwd beneath that user's home. `prox_close` reads the session marker for tty+pid and closes locally only (refuses the calling session). This is how a `machine:promptname` handle resolves without an SSH crawl. -- **Loopboy** (`~/.config/slab/loopboy.json`, surfaced in the Slab menubar) — the primary client-loop interface. Each route maps one private iMessage contact key to one stable local prox session; new inbound messages poke and optionally wake only that contact's rock. Loopboy never replies by itself. Armed rocks spin faster, glow pink, and identify themselves as Loopboy on hover. Create or replace a route with `prox_bind_notification(handle, contact)`. +- **Prompt rocks MCP** (`slab/bin/prox-mcp.mjs`, registered as `prox` in `.mcp.json`) — an MCP over the fleet handle ledger (`Ledger.swift`; `~/.config/slab/ledger/{local,peers/*}.json`, served per-machine on tailnet-only `:5252`) so any agent can `prox_list` every live session across machines, `prox_find` a `host:name` reference (e.g. `neo:regif`) to its status/subject/cwd/seed, `prox_poke` one (`POST /poke` → the target rock blinks + rattles), `prox_wake` a local one with a bounded steering prompt, send `prox_artifact_ready` the output paths from an asynchronous render, and `prox_launch` a new Claude/Codex Terminal on a prompt host. Wake uses the same poke + TTY reactivation pattern as Loopboy; `prox_artifact_ready` supplies the standard inspect/iterate/integrate/continue prompt itself. Launch is deliberately not a remote shell: the target accepts only those two fixed agents, a bounded prompt, and a cwd beneath that user's home. `prox_close` reads the session marker for tty+pid and closes locally only; it refuses ordinary calling sessions, while a guarded Loopboy may release its own matching route and schedule a self-close after returning its receipt. This is how a `machine:promptname` handle resolves without an SSH crawl. +- **Loopboy** (`~/.config/slab/loopboy.json`, surfaced in the Slab menubar) — the primary client-loop interface. Each route maps one private iMessage contact key to one stable local prox session; new inbound messages poke and optionally wake only that contact's rock. Loopboy never replies by itself. Armed rocks spin faster, glow pink, and identify themselves as Loopboy on hover. Create or replace a route with `prox_bind_notification(handle, contact)`. “Shut this Loopboy down” means: stop its listener loop, finish and compush in-scope changes, then call `prox_close` on its own stable handle as the final action. The guarded self-close releases the contact route, closes its Terminal pane asynchronously, and lets Slab's terminal-population watcher re-tile the remaining panes; never substitute Terminal/clipboard/mouse/keyboard/GUI injection. - **ZZZ** (`slab/bin/zzz`, surfaced in the Slab menubar) — the global name for parking an idle prompt: persist its agent/provider thread, cwd, subject, and tty under `~/.local/share/slab/state/zzz/`, end its process, and leave its terminal open with a colored `zzz resume ` receipt. Automatic parking is opt-in with a 60-minute default, and never parks working, awaiting, rendering, remote, subagent-owning, or Loopboy-bound prompts. Run `zzz` for the mouse-aware harness; tap a sleeping row (or use arrows + Return) to resume it through the tracked Claude/Codex launcher. `zzz list` prints a `zzz resume N` command for each row, plain `zzz resume` wakes the newest, and `zzz park ` parks explicitly; `--force` is required when a prompt is not known to be idle. - **Generated artifacts are not task completion** — when an image, PDF, audio file, video, or other render lands, open it automatically, inspect it against the request, iterate if needed, move the accepted artifact into the project, and connect it to its intended consumer before ending the task. If an asynchronous render finishes after the originating rock has gone idle, its completion handler should call `prox_artifact_ready` with that stable session and the output paths; prox wakes the rock and supplies the bounded continuation instruction. - **Paper MCP** (`slab/bin/paper-mcp.mjs`, registered as `paper`) — one searchable front door to the public AC paper tree and the private vault paper stacks, including `fuser/papers`. `paper_list`/`paper_find` resolve a stable paper id, `paper_read` returns bounded TeX/Markdown/PDF text, `paper_build` runs XeLaTeX or Tectonic in the source directory, and `paper_open` raises the result through `slab-pdf`. The shared loopback daemon is `:7777`; `toolchain/mcp/install-daemons.sh` registers it for Claude and Codex. A build may pass `notifyHandle` to return its PDF to the originating rock through `prox_artifact_ready`. diff --git a/captutor/lib/tutorial-layout.mjs b/captutor/lib/tutorial-layout.mjs index fb36655c3..e6ccad991 100644 --- a/captutor/lib/tutorial-layout.mjs +++ b/captutor/lib/tutorial-layout.mjs @@ -89,6 +89,39 @@ function targetSelector(nodeSelector, index, count, options) { })()`; } +function placementExpression(nodeSelector, index, count, options) { + return `(() => { + const node = document.querySelector(${JSON.stringify(nodeSelector)}); + if (!node) return null; + const nodeRect = node.getBoundingClientRect(); + const chatButton = [...document.querySelectorAll('button')].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && + (element.innerText || '').trim() === 'Continue the chat'; + }); + const chatTop = chatButton?.getBoundingClientRect().top; + const safe = { + left:${options.insets.left}, top:${options.insets.top}, + right:innerWidth - ${options.insets.right}, + bottom:Math.min( + innerHeight - ${options.insets.bottom}, + Number.isFinite(chatTop) ? chatTop - 20 : innerHeight, + ), + }; + const safeWidth = safe.right - safe.left; + const groupWidth = nodeRect.width * ${count} + ${options.gap} * Math.max(0, ${count} - 1); + const desiredLeft = safe.left + (safeWidth - groupWidth) / 2 + + ${index} * (nodeRect.width + ${options.gap}); + const desiredTop = safe.top + Math.max(0, (safe.bottom - safe.top - nodeRect.height) / 2); + return { + dx:desiredLeft - nodeRect.left, + dy:desiredTop - nodeRect.top, + current:{ left:nodeRect.left, top:nodeRect.top }, + desired:{ left:desiredLeft, top:desiredTop }, + }; + })()`; +} + // Drive React Flow's continuous pinch-zoom path rather than opening its menu. // This lets the teaching layout land around 80% (large enough to read, small // enough to clear the chat composer) without filming a mystery menu detour. @@ -144,17 +177,34 @@ export async function frameTutorialNodes(ctx, nodes, { gap = 64, moveMs = 380, dragMs = 480, + maxAttempts = 3, + tolerance = 6, } = {}) { - const { drag, sleep } = ctx; + const { cdp, drag, sleep } = ctx; + const placements = []; for (let index = 0; index < nodes.length; index += 1) { const node = nodes[index]; - await drag( - titleSelector(node.selector, node.title), - targetSelector(node.selector, index, nodes.length, { title:node.title, insets, gap }), - { moveMs, dragMs }, - ); - await sleep(180); + let placement = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await drag( + titleSelector(node.selector, node.title), + targetSelector(node.selector, index, nodes.length, { title:node.title, insets, gap }), + { moveMs, dragMs }, + ); + await sleep(180); + placement = await cdp.eval(placementExpression( + node.selector, index, nodes.length, { insets, gap }, + )); + if (placement && Math.abs(placement.dx) <= tolerance && + Math.abs(placement.dy) <= tolerance) break; + } + if (!placement || Math.abs(placement.dx) > tolerance || + Math.abs(placement.dy) > tolerance) { + throw new Error(`Tutorial node placement did not converge for ${node.title}: ${JSON.stringify(placement)}`); + } + placements.push({ selector:node.selector, title:node.title, ...placement }); } + return placements; } export async function tutorialLayoutScores(cdp, selectors, { diff --git a/captutor/screenplays/talking-taco-continuation.mjs b/captutor/screenplays/talking-taco-continuation.mjs index 96a967cc7..82832d85b 100644 --- a/captutor/screenplays/talking-taco-continuation.mjs +++ b/captutor/screenplays/talking-taco-continuation.mjs @@ -56,7 +56,6 @@ export default { { selector:".react-flow__node-FalGeminiImageNode", title:"Gemini Image" }, ]; await frameTutorialNodes(ctx, imageNode); - await frameTutorialNodes(ctx, imageNode, { moveMs:220, dragMs:300 }); await assertTutorialLayout(ctx, [".react-flow__node-FalGeminiImageNode"]); }, // Resume at "There it is". The preceding reel already includes the real diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index c14d4afe1..9f1406867 100755 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -21,7 +21,7 @@ // Hand-rolled JSON-RPC over stdio, matching the house style of the sibling // frame-mcp / puppet-mcp — no SDK, only node builtins + the shared front. import { readFile, readdir, writeFile, mkdir, rename } from "node:fs/promises"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; import { homedir, hostname } from "node:os"; @@ -283,6 +283,48 @@ end tell`; catch { return 0; } } +// A calling Loopboy cannot synchronously close the terminal that must carry +// its MCP response. Hand the close to a detached helper, giving the agent a +// brief window to return its shutdown receipt. Slab's terminal-population +// watcher observes the close and re-tiles the remaining panes. +function scheduleTerminalClose(tty, pid, delayMs = 4_000) { + const dev = tty.startsWith("/dev/") ? tty : `/dev/${tty}`; + if (!/^\/dev\/(ttys\d+|pts\/\d+)$/.test(dev)) { + throw new Error(`refusing unsafe tty marker: ${tty}`); + } + const osa = `tell application "Terminal" + set n to 0 + repeat with w in windows + repeat with t in tabs of w + try + if (tty of t) is "${dev}" then + close w saving no + set n to n + 1 + end if + end try + end repeat + end repeat + return n +end tell`; + const helper = ` +const { execFile } = require("node:child_process"); +const script = process.argv[1]; +const pid = Number(process.argv[2]) || 0; +const delay = Number(process.argv[3]) || 4000; +setTimeout(() => { + execFile("/usr/bin/osascript", ["-e", script], { timeout: 8000 }, () => { + if (pid > 1) setTimeout(() => { try { process.kill(pid, "SIGTERM"); } catch {} }, 750); + }); +}, delay); +`; + const child = spawn(process.execPath, ["-e", helper, osa, String(pid || 0), String(delayMs)], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return child.pid; +} + // ── tools ───────────────────────────────────────────────────────────────────── async function toolList({ host, status, kind, agent } = {}) { const now = Date.now(); @@ -694,7 +736,7 @@ async function toolLoopboyWait({ handle, contact, timeoutSeconds = 50 }, context }]; } -async function toolClose({ handle }) { +async function toolClose({ handle }, context = {}) { if (!handle) throw new Error("`handle` is required (a canonical host:name#id, exact host:name, unique pet name, or session id; see prox_find)."); const r = actionResolution(await allRocks(), handle, "close"); // Closing means killing a process + shutting its terminal window — only doable @@ -707,9 +749,37 @@ async function toolClose({ handle }) { // legacy `claude_pid` so existing Claude markers still close. const pid = mk?.agent_pid || mk?.claude_pid || 0; if (!tty && !pid) throw new Error(`no live tty/pid marker for ${r.host}:${r.name} (id ${r.id.slice(0, 8)}) — it may already be gone.`); - // Never close the session that is asking. + // Ordinary prompts can never close themselves. A guarded Loopboy is the + // narrow exception: an authenticated matching launch identity may release + // its own route and schedule its own terminal close after the MCP receipt. const anc = await ancestorPids(process.pid); - if (pid && anc.has(pid)) throw new Error(`refusing to close ${r.host}:${r.name} — that is the session calling prox_close.`); + let callerIdentity = null; + try { callerIdentity = loopboyWaitIdentity({ context }); } catch {} + const identifiedSelf = callerIdentity && String(callerIdentity.sessionId) === String(r.id); + const processSelf = pid && anc.has(pid); + if (identifiedSelf || processSelf) { + const contact = String(callerIdentity?.contact || "").toLowerCase(); + if (!identifiedSelf || !contact || String(r.loopboyContact || "").toLowerCase() !== contact) { + throw new Error(`refusing to close ${r.host}:${r.name} — that is the session calling prox_close.`); + } + if (!tty) throw new Error(`no live tty marker for guarded Loopboy ${canonicalHandle(r)}`); + const cfg = (await readJson(LOOPBOY_CONFIG)) || { version: 1, loops: {} }; + cfg.loops ||= {}; + if (String(cfg.loops[contact]?.sessionId || "") !== String(r.id)) { + throw new Error(`this Loopboy session is not the bound ${contact} listener`); + } + delete cfg.loops[contact]; + await writeLoopboyConfig(cfg); + if (process.env.SLAB_PROX_CLOSE_DRY_RUN !== "1") { + scheduleTerminalClose(tty, pid); + } + return [{ + type: "text", + text: `checked_at: ${isoTime(r.resolvedAt)}\n` + + `scheduled guarded Loopboy shutdown for ${canonicalHandle(r)} — ` + + `released ${contact} route; terminal closes after the receipt; Slab re-tiles the remaining panes.`, + }]; + } const steps = []; // Graceful first, then force. claude traps SIGTERM, so don't wait long on it. if (pid && pidAlive(pid)) { @@ -812,7 +882,7 @@ const TOOLS = [ { name: "prox_close", description: - "Close a prompt rock and its terminal window. DESTRUCTIVE: requires a fresh ledger and a canonical host:name#id, session id, exact host:name, or fleet-unique exact pet name; refuses fuzzy, duplicate, stale, remote, or calling-session targets.", + "Close a prompt rock and its terminal window. DESTRUCTIVE: requires a fresh ledger and a canonical host:name#id, session id, exact host:name, or fleet-unique exact pet name; refuses fuzzy, duplicate, stale, and remote targets. Ordinary sessions cannot close themselves; a guarded Loopboy may release its own route and schedule a self-close so Slab can re-tile.", inputSchema: { type: "object", properties: { @@ -903,7 +973,7 @@ async function callTool(name, args, context) { case "prox_loopboy_wait": return toolLoopboyWait(args || {}, context); case "prox_loopboy_agent_status": return toolLoopboyAgentStatus(args || {}); case "prox_loopboy_agent_nudge": return toolLoopboyAgentNudge(args || {}); - case "prox_close": return toolClose(args || {}); + case "prox_close": return toolClose(args || {}, context); default: throw new Error(`Unknown tool: ${name}`); } } diff --git a/slab/menubar-swift/Info.plist b/slab/menubar-swift/Info.plist index 2dba51a14..dc31e3b93 100644 --- a/slab/menubar-swift/Info.plist +++ b/slab/menubar-swift/Info.plist @@ -28,6 +28,8 @@ NSAppleEventsUsageDescription Slab links each prompt terminal to its status rock and platform target badge. + NSScreenCaptureUsageDescription + Slab captures requested frames and tutorial recordings for local visual automation. diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index 540b6c802..aa5c01397 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -141,6 +141,14 @@ launcher; editing `loopboy.json` cannot retrofit an ordinary prompt. The Slab menu counts verified client loops and labels stale or mismatched saved routes as inactive. +“Shut this Loopboy down” is a lifecycle command: finish and compush in-scope +changes, then call `prox_close` on the Loopboy's own stable handle. Only the +guarded session whose immutable contact identity matches the active route may +self-close. Prox releases that route first, returns a shutdown receipt, and +then closes the terminal asynchronously; Slab's terminal-population watcher +re-tiles the remaining panes. Ordinary prompts remain unable to close the +calling session. + ## ZZZ — resumable prompt parking `zzz` is Slab's cold tier for prompt processes. Automatic parking is opt-in; diff --git a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift index bd7633833..cc6617105 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift @@ -716,8 +716,14 @@ final class FrameCapture { var capturedScope = "screen" Task { defer { sem.signal() } - guard let content = try? await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) else { return } + let content: SCShareableContent + do { + content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true) + } catch { + NSLog("Frame capture: shareable content failed: %@", String(describing: error)) + return + } if crop == nil, let focusedWindowID, let window = content.windows.first(where: { $0.windowID == focusedWindowID }) { let region = window.frame @@ -731,8 +737,12 @@ final class FrameCapture { cfg.ignoreShadowsSingleWindow = true capturedRegion = region capturedScope = "window" - img = try? await SCScreenshotManager.captureImage( - contentFilter: filter, configuration: cfg) + do { + img = try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: cfg) + } catch { + NSLog("Frame capture: focused-window screenshot failed: %@", String(describing: error)) + } return } guard let display = content.displays.first else { return } @@ -759,7 +769,11 @@ final class FrameCapture { cfg.width = Int(Double(region.width) * self.captureScale) cfg.height = Int(Double(region.height) * self.captureScale) cfg.showsCursor = true - img = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg) + do { + img = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg) + } catch { + NSLog("Frame capture: display screenshot failed: %@", String(describing: error)) + } } sem.wait() return (img, capturedRegion, capturedScope) diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index f952d2dfa..e82395631 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -533,10 +533,6 @@ final class PromptSigilOverlay { heartbeatTrackLayer.frame = CGRect(x: 0, y: 24, width: heartbeatInitial.width, height: 3) heartbeatTrackLayer.backgroundColor = NSColor(deviceWhite: 0.025, alpha: 0.38).cgColor heartbeatTrackLayer.cornerRadius = 0 - heartbeatTrackLayer.shadowColor = NSColor.systemYellow.cgColor - heartbeatTrackLayer.shadowOpacity = 0.42 - heartbeatTrackLayer.shadowRadius = 3 - heartbeatTrackLayer.shadowOffset = CGSize(width: 0, height: -1) heartbeatTrackLayer.isHidden = true heartbeatContainer.layer?.addSublayer(heartbeatTrackLayer) heartbeatFillLayer.frame = heartbeatTrackLayer.bounds @@ -547,10 +543,6 @@ final class PromptSigilOverlay { y: heartbeatTrackLayer.bounds.midY) heartbeatFillLayer.backgroundColor = NSColor(deviceRed: 1, green: 0.9, blue: 0.18, alpha: 1).cgColor - heartbeatFillLayer.shadowColor = NSColor(deviceRed: 1, green: 0.32, - blue: 0.04, alpha: 1).cgColor - heartbeatFillLayer.shadowOpacity = 1 - heartbeatFillLayer.shadowRadius = 6 heartbeatFillLayer.cornerRadius = 0 heartbeatTrackLayer.addSublayer(heartbeatFillLayer) @@ -706,7 +698,6 @@ final class PromptSigilOverlay { heartbeatFillLayer.opacity = 1 heartbeatFillLayer.transform = CATransform3DIdentity heartbeatFillLayer.backgroundColor = heartbeatColor.cgColor - heartbeatFillLayer.shadowColor = heartbeatColor.cgColor CATransaction.commit() // Fill edge and ember are laid out from one clock in // updateHeartbeatCountdown. Keeping no independent x animations means @@ -1031,7 +1022,6 @@ final class PromptSigilOverlay { shadowColor = color shadowLayer.backgroundColor = color.cgColor heartbeatTrackLayer.backgroundColor = NSColor(deviceWhite: 0.025, alpha: 0.38).cgColor - heartbeatTrackLayer.shadowColor = NSColor.black.cgColor // The lettering's hard shadow wears the same status colour, so the // label reads as part of the same lit object as the rock. rebuildName() @@ -1051,7 +1041,6 @@ final class PromptSigilOverlay { CATransaction.begin() CATransaction.setDisableActions(true) heartbeatFillLayer.backgroundColor = color.cgColor - heartbeatFillLayer.shadowColor = color.cgColor CATransaction.commit() } diff --git a/slab/test/prox-mcp.test.mjs b/slab/test/prox-mcp.test.mjs index 828f34bcf..3df0271c9 100644 --- a/slab/test/prox-mcp.test.mjs +++ b/slab/test/prox-mcp.test.mjs @@ -216,3 +216,40 @@ test("a second live Loopboy cannot steal an active contact route", async () => { }, { SLAB_LOOPBOY_CONTACT: "alex", SLAB_PROMPT_SESSION_ID: callerId }); assert.match(text, /not the bound alex listener.*neo:owner#aaaaaaaa/); }); + +test("a guarded Loopboy can release its route and schedule its own shutdown", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + const slabDir = join(home, ".config", "slab"); + const ledgerDir = join(slabDir, "ledger"); + const markerDir = join(home, ".local", "share", "slab", "state", "active-prompts"); + await mkdir(join(ledgerDir, "peers"), { recursive: true }); + await mkdir(markerDir, { recursive: true }); + const now = Date.now(); + const id = "cccccccc-1111-2222-3333-444444444444"; + await writeFile(join(ledgerDir, "local.json"), JSON.stringify({ + host: "neo", ip: "127.0.0.1", updatedAt: now, + entries: [{ + id, host: "neo", name: "closer", subject: "alex listener", + status: "working", kind: "session", cwd: home, updated: now, + started: now, loopboyContact: "alex", agentType: "codex", + }], + })); + await writeFile(join(markerDir, id), JSON.stringify({ + id, tty: "ttys999", agent_pid: process.pid, + })); + await writeFile(join(slabDir, "loopboy.json"), JSON.stringify({ + version: 1, + loops: { alex: { contact: "alex", sessionId: id, host: "neo", name: "closer" } }, + })); + + const text = await callProx(home, "prox_close", { handle: id }, { + SLAB_LOOPBOY_CONTACT: "alex", + SLAB_PROMPT_SESSION_ID: id, + SLAB_PROX_CLOSE_DRY_RUN: "1", + }); + assert.match(text, /scheduled guarded Loopboy shutdown/); + assert.match(text, /released alex route/); + assert.match(text, /Slab re-tiles/); + const config = JSON.parse(await readFile(join(slabDir, "loopboy.json"), "utf8")); + assert.equal(config.loops.alex, undefined); +}); -- 2.51.2