diff --git a/SCORE.md b/SCORE.md index c753250637..280869bc79 100644 --- a/SCORE.md +++ b/SCORE.md @@ -247,7 +247,7 @@ This is critical because `lib/pmove.mjs` is shared physics: client (lith) and se - **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)`. +- **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)`; an ordinary running Claude rock becomes a Loopboy without closing its window via `adopt=true` (optionally `name` for a new pet name) — the tool stamps the live marker, the prompt hook keeps the stamp, and the rock re-forms as a gem with the cadence strip on the next menubar refresh. - **Paper MCP / `/papers` stack** (`slab/bin/paper-mcp.mjs`, registered as `paper`) — “use the papers stack,” “use `/papers`,” and similar requests name the studio's scholarly publishing workflow, not merely a request to export or prettify a PDF. Begin by consulting [`papers/SCORE.md`](papers/SCORE.md), the public Platter index, relevant sub-platters, prior papers and bibliographies, and the underlying code/data/evidence. Unless the user names another mill lane, shape the result as an archival/arXiv-style LaTeX paper: title/byline/date, abstract, problem and context, related work, system or method, implementation, evidence/evaluation, ethics/privacy/limitations, conclusion, references, numbered/captioned figures and tables, and reproducible source/assets. Briefings, decks, cards, dossiers, and visual reports are distinct lanes and require explicit intent or strong task evidence. The MCP is the transport/build/inspection layer: `paper_list`/`paper_find` locate precedent, `paper_read` supports the Platter consult, `paper_build` runs XeLaTeX or Tectonic, `paper_figure_table_qa_check` supports mandatory visual inspection, 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`. - `slab/bin/ac-passphrase` — pinentry-free secret fetch from the daemon (see Development Environment below). diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh index 5faa92a3b9..3214b63781 100755 --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -42,6 +42,10 @@ 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)} # 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 @@ -68,7 +72,7 @@ if [[ -n "$input" ]]; then --arg ts "$ts" \ --arg started "$started_at" \ --arg sum "$summary" \ - --arg nudge "${SLAB_NUDGE_SCREEN:-}" --arg contact "${SLAB_LOOPBOY_CONTACT:-}" \ + --arg nudge "${SLAB_NUDGE_SCREEN:-}" --arg contact "$contact" \ '{session_id: $sid, cwd: .cwd, subject: (.prompt | tostring | .[0:140]), summary: $sum, tty: $tty, claude_pid: ($pid | tonumber? // 0), agent_pid: ($pid | tonumber? // 0), agent_type: "claude", updated: $ts, state: "working", nudge_screen:$nudge, loopboy_contact:$contact}' \ > "$ACTIVE_DIR/$session_id" 2>/dev/null diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index bf23534f48..d6bb5b8ca1 100755 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -476,22 +476,65 @@ async function toolJob({ host, job = "mediascholar", action = "status" }) { return [{ type: "text", text: `${target.host}:${jobName} ${jobAction} — ${detail}` }]; } -async function toolBindNotification({ handle, contact, event = "imessage", wake = true }) { +// ── adoption: convert a running ordinary rock into a Loopboy in place ────── +// A launched Loopboy gets its contact from SLAB_LOOPBOY_CONTACT at process +// start, and the prompt hook copies that into the session marker on every +// prompt. Adoption stamps the same field onto a live Claude marker instead; +// the hook keeps a stamped contact across rewrites, so the menubar verifies +// the route on its next refresh and the rock re-forms as a gem with the +// cadence strip — Terminal window and conversation untouched. +async function adoptRock(r, contactKey) { + if (isCodexBacked(r.agentType)) { + throw new Error( + `${r.host}:${r.name} is a ${r.agentType} session; Codex-backed Loopboys need their ` + + `contact headers at launch — start one with prox_launch and loopboyContact=${contactKey}`, + ); + } + const path = join(MARKER_DIRS[0], r.id); + const marker = await readJson(path); + if (!marker) throw new Error(`${r.host}:${r.name} has no live session marker to adopt (is its Terminal still open?)`); + const pid = Number(marker.claude_pid || marker.agent_pid || 0); + if (!pid || !pidAlive(pid)) throw new Error(`${r.host}:${r.name} marker points at a dead process (pid ${pid || "?"})`); + marker.loopboy_contact = contactKey; + await writeFile(path, JSON.stringify(marker) + "\n"); + return `adopted in place: marker ${r.id.slice(0, 8)} stamped loopboy_contact=${contactKey}; the rock re-forms as a gem on the next menubar refresh.`; +} + +async function toolBindNotification({ handle, contact, event = "imessage", wake = true, adopt = false, name = "" }) { 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(); if (!contactKey) throw new Error("`contact` is required (the key from ~/.config/slab/imsg.json)"); + const petName = String(name || "").trim().toLowerCase(); + if (petName && !/^[a-z][a-z0-9_-]{1,15}$/.test(petName)) { + throw new Error("`name` must be a short lowercase pet name (2–16 letters, digits, - or _)"); + } const hits = resolve(await allRocks(), handle); if (!hits.length) throw new Error(`no rock resolves «${handle}» to bind.`); 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("iMessage notification wake targets must be a local prox on this machine"); + const launched = String(r.loopboyContact || "").trim().toLowerCase(); + if (launched && launched !== contactKey) { + throw new Error(`${r.host}:${r.name} was launched for ${launched}, not ${contactKey}`); + } + let adoption = ""; + if (!launched) { + if (!adopt) { + throw new Error( + `${r.host}:${r.name} was not launched as a guarded Loopboy; pass adopt=true to convert it in place, ` + + `or start a dedicated one with prox_launch and loopboyContact=${contactKey}`, + ); + } + adoption = `\n${await adoptRock(r, contactKey)}`; + } const loop = { event: "imessage", contact: contactKey, sessionId: r.id, host: r.host, - name: r.name, + name: petName || r.name, + agent: r.agentType || "claude", wake: wake !== false, assignedAt: new Date().toISOString(), }; @@ -501,7 +544,7 @@ async function toolBindNotification({ handle, contact, event = "imessage", wake 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}:${loop.name} (${r.id}) — poke${loop.wake ? " + reactivate" : " only"}.${adoption}` }]; } async function toolClose({ handle }) { @@ -645,6 +688,8 @@ const TOOLS = [ 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." }, + adopt: { type: "boolean", default: false, description: "Convert an ordinary running Claude rock into this contact's Loopboy in place: stamps its live marker, keeps the window open, and the rock re-forms as a gem." }, + name: { type: "string", description: "Optional pet name for the Loopboy, for example surizo. Defaults to the rock's current name." }, }, required: ["handle", "contact"], }, diff --git a/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift index 9414819d7a..3b0939c72f 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/LoopboyRoutes.swift @@ -1,9 +1,10 @@ import Foundation -/// Saved Loopboy routing and the live launch identity are deliberately +/// Saved Loopboy routing and the live session identity are deliberately /// separate. A route is operational only when both agree: editing the JSON -/// registry cannot retrofit the environment, MCP headers, or reduced tool -/// surface that a guarded Loopboy receives at process launch. +/// 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. struct LoopboyRoute { let contact: String let channel: String diff --git a/slab/test/prox-mcp.test.mjs b/slab/test/prox-mcp.test.mjs index 3df0271c95..1c904b750d 100644 --- a/slab/test/prox-mcp.test.mjs +++ b/slab/test/prox-mcp.test.mjs @@ -253,3 +253,72 @@ test("a guarded Loopboy can release its route and schedule its own shutdown", as const config = JSON.parse(await readFile(join(slabDir, "loopboy.json"), "utf8")); assert.equal(config.loops.alex, undefined); }); + +async function ordinaryRock(home, id, extra = {}) { + const slabDir = join(home, ".config", "slab"); + const ledgerDir = join(slabDir, "ledger"); + const markers = join(home, ".local", "share", "slab", "state", "active-prompts"); + await mkdir(join(ledgerDir, "peers"), { recursive: true }); + await mkdir(markers, { recursive: true }); + const now = Date.now(); + await writeFile(join(ledgerDir, "local.json"), JSON.stringify({ + host: "neo", ip: "127.0.0.1", updatedAt: now, + entries: [{ + id, host: "neo", name: "surizu", subject: "for her", agentType: "claude", + status: "complete", kind: "session", seed: "9abc", cwd: home, + updated: now, started: now - 5_000, ...extra, + }], + })); + await writeFile(join(markers, id), JSON.stringify({ + session_id: id, cwd: home, subject: "for her", summary: "for her", tty: "ttys006", + claude_pid: process.pid, agent_pid: process.pid, agent_type: "claude", + updated: new Date(now).toISOString(), state: "complete", nudge_screen: "", loopboy_contact: "", + }) + "\n"); + return { slabDir, marker: join(markers, id) }; +} + +test("adopt converts an ordinary Claude rock in place and renames it", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + const id = "cccccccc-1111-2222-3333-444444444444"; + const { slabDir, marker } = await ordinaryRock(home, id); + const env = { SLAB_HOME: join(home, ".local", "share", "slab") }; + + const refused = await callProx(home, "prox_bind_notification", { + handle: "neo:surizu", contact: "fia", + }, env); + assert.match(refused, /was not launched as a guarded Loopboy; pass adopt=true/); + assert.equal(JSON.parse(await readFile(marker, "utf8")).loopboy_contact, ""); + + const bound = await callProx(home, "prox_bind_notification", { + handle: "neo:surizu", contact: "fia", adopt: true, name: "surizo", + }, env); + assert.match(bound, /Loopboy bound fia → neo:surizo/); + assert.match(bound, /adopted in place: marker cccccccc stamped loopboy_contact=fia/); + const stamped = JSON.parse(await readFile(marker, "utf8")); + assert.equal(stamped.loopboy_contact, "fia"); + assert.equal(stamped.claude_pid, process.pid); + const config = JSON.parse(await readFile(join(slabDir, "loopboy.json"), "utf8")); + assert.equal(config.loops.fia.sessionId, id); + assert.equal(config.loops.fia.name, "surizo"); + assert.equal(config.loops.fia.agent, "claude"); + assert.equal(config.loops.fia.wake, true); +}); + +test("adopt refuses Codex-backed rocks and rocks guarded for someone else", async () => { + const home = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + const id = "dddddddd-1111-2222-3333-444444444444"; + const { marker } = await ordinaryRock(home, id, { agentType: "codex" }); + const env = { SLAB_HOME: join(home, ".local", "share", "slab") }; + const codex = await callProx(home, "prox_bind_notification", { + handle: "neo:surizu", contact: "fia", adopt: true, + }, env); + assert.match(codex, /is a codex session; Codex-backed Loopboys need their contact headers at launch/); + assert.equal(JSON.parse(await readFile(marker, "utf8")).loopboy_contact, ""); + + const other = await mkdtemp(join(tmpdir(), "prox-mcp-test-")); + await ordinaryRock(other, id, { loopboyContact: "alex" }); + const foreign = await callProx(other, "prox_bind_notification", { + handle: "neo:surizu", contact: "fia", adopt: true, + }, { SLAB_HOME: join(other, ".local", "share", "slab") }); + assert.match(foreign, /was launched for alex, not fia/); +});