diff --git a/slab/bin/prox-mcp.mjs b/slab/bin/prox-mcp.mjs index c70344dfee..73f8d7f6d5 100644 --- a/slab/bin/prox-mcp.mjs +++ b/slab/bin/prox-mcp.mjs @@ -20,7 +20,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 } from "node:fs/promises"; +import { readFile, readdir, writeFile, mkdir, copyFile, chmod } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; @@ -42,6 +42,41 @@ const LOOPBOY_CONFIG = join(homedir(), ".config", "slab", "loopboy.json"); const SLAB_HOME = process.env.SLAB_HOME || join(homedir(), ".local", "share", "slab"); const MARKER_DIRS = [join(SLAB_HOME, "state", "active-prompts"), join(SLAB_HOME, "state", "awaiting-prompts")]; +const shellQuote = (s) => `'${String(s).replaceAll("'", `'"'"'`)}'`; + +async function findFile(root, suffix) { + let entries; + try { entries = await readdir(root, { withFileTypes: true }); } catch { return null; } + for (const entry of entries) { + const path = join(root, entry.name); + if (entry.isFile() && entry.name.endsWith(suffix)) return path; + if (entry.isDirectory()) { + const hit = await findFile(path, suffix); + if (hit) return hit; + } + } + return null; +} + +async function transcriptFor(rock, marker) { + if (marker?.transcript_path) return marker.transcript_path; + const agent = marker?.agent_type || rock.agentType || "claude"; + const providerId = marker?.provider_session_id || marker?.codex_session_id || rock.id; + if (agent === "codex") { + return findFile(join(homedir(), ".codex", "sessions"), `${providerId}.jsonl`); + } + return findFile(join(homedir(), ".claude", "projects"), `${rock.id}.jsonl`); +} + +async function renderRockBundle(seed, bundle) { + if (!seed) return false; + const exporter = join(import.meta.dirname, "prox-sigil-export"); + try { + await pexec(exporter, [seed, bundle, "dark"], { timeout: 60_000 }); + return true; + } catch { return false; } +} + // ── load the fleet ledger off disk ────────────────────────────────────────── async function readJson(path) { try { @@ -236,6 +271,65 @@ async function toolPoke({ handle, by }) { return [{ type: "text", text: `poked ${r.host}:${r.name} as «${poker}» — its rock should blink + rattle (HTTP ${res.status}).` }]; } +async function toolDump({ handle, destination } = {}) { + if (!handle) throw new Error("`handle` is required (a local `host:name` or fuzzy name; see prox_find)."); + const hits = resolve(await allRocks(), handle); + if (!hits.length) throw new Error(`no rock resolves «${handle}» to dump.`); + if (hits.length > 1) { + return [{ type: "text", text: `«${handle}» is ambiguous (${hits.map((r) => `${r.host}:${r.name}`).join(", ")}). Dump a specific host:name.` }]; + } + const r = hits[0]; + if (!r.self) throw new Error(`${r.host}:${r.name} runs on another machine — prox_dump is local-only so raw session state never crosses the ledger server. Run it from ${r.host}.`); + const marker = await readMarker(r.id); + if (!marker) throw new Error(`no local session marker remains for ${r.host}:${r.name}.`); + const transcript = await transcriptFor(r, marker); + if (!transcript) throw new Error(`could not locate the persisted transcript for ${r.host}:${r.name}.`); + + const agent = marker.agent_type || r.agentType || "claude"; + const providerId = marker.provider_session_id || marker.codex_session_id || r.id; + const base = destination ? String(destination) : join(homedir(), "Desktop"); + const safeName = `${r.host}-${r.name}`.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const out = join(base, `${safeName}.prox`); + await mkdir(out, { recursive: false, mode: 0o700 }); + await copyFile(transcript, join(out, "transcript.jsonl")); + await chmod(join(out, "transcript.jsonl"), 0o600); + const manifest = { + format: "computer.aesthetic.prox-dump/v1", + dumpedAt: new Date().toISOString(), + host: r.host, name: r.name, sessionId: r.id, providerSessionId: providerId, + agent, cwd: marker.cwd || r.cwd || "", subject: marker.subject || r.subject || "", + seed: r.seed || "", status: r.status || marker.state || "", + }; + await writeFile(join(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 }); + + const resume = agent === "codex" ? `#!/bin/sh +set -eu +bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +day=$(date +%Y/%m/%d) +store="$HOME/.codex/sessions/$day" +mkdir -p "$store" +cp "$bundle/transcript.jsonl" "$store/rollout-prox-${providerId}.jsonl" +cd ${shellQuote(marker.cwd || r.cwd || homedir())} 2>/dev/null || cd "$HOME" +exec codex resume ${shellQuote(providerId)} +` : `#!/bin/sh +set -eu +bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +project=${shellQuote((marker.cwd || r.cwd || homedir()).replaceAll("/", "-") || "-")} +store="$HOME/.claude/projects/$project" +mkdir -p "$store" +cp "$bundle/transcript.jsonl" "$store/${r.id}.jsonl" +cd ${shellQuote(marker.cwd || r.cwd || homedir())} 2>/dev/null || cd "$HOME" +exec claude --resume ${shellQuote(r.id)} +`; + await writeFile(join(out, "resume.sh"), resume, { mode: 0o700 }); + await chmod(join(out, "resume.sh"), 0o700); + await writeFile(join(out, "README.txt"), + `Portable prox state for ${r.host}:${r.name}\n\nRun ./resume.sh to install the transcript into ${agent}'s native session store and resume it.\nThe animated sigil.gif is rendered from the prompt rock's exact seeded 3D model.\nThis bundle contains raw private agent history, including tool results and local paths. Do not publish it.\n`, + { mode: 0o600 }); + const rendered = await renderRockBundle(r.seed, out); + return [{ type: "text", text: `dumped ${r.host}:${r.name} → ${out}\nagent: ${agent}\nresume id: ${providerId}\nrock: ${rendered ? "animated exact-model sigil.gif + Finder icon" : "renderer unavailable; state bundle is still complete"}\nprivate raw transcript included; move the .prox folder as one bundle.` }]; +} + async function toolLaunch({ host, agent, cwd, prompt = "", by, loopboyContact = "" }) { const wanted = String(host || "").trim().toLowerCase().replace(/\.local$/, ""); if (!wanted) throw new Error("`host` is required (for example, poorslice)."); @@ -425,6 +519,19 @@ const TOOLS = [ required: ["handle"], }, }, + { + name: "prox_dump", + description: + "Export one local prompt rock as a portable, resumable private bundle. Copies the raw native transcript plus session/cwd metadata and writes a resume.sh installer. Defaults to ~/Desktop/-.prox. Raw transcripts can contain tool output and local paths, so the bundle must remain private. Local machine only; no transcript data is sent over the fleet ledger.", + inputSchema: { + type: "object", + properties: { + handle: { type: "string", description: "A local host:name, session id, or unambiguous fuzzy name." }, + destination: { type: "string", description: "Optional existing destination directory. Defaults to ~/Desktop." }, + }, + required: ["handle"], + }, + }, { name: "prox_launch", description: @@ -467,6 +574,7 @@ async function callTool(name, args) { case "prox_launch": return toolLaunch(args || {}); case "prox_bind_notification": return toolBindNotification(args || {}); case "prox_close": return toolClose(args || {}); + case "prox_dump": return toolDump(args || {}); default: throw new Error(`Unknown tool: ${name}`); } } @@ -508,4 +616,4 @@ async function handleMessage(message) { const port = httpPort(process.argv, 7773); if (port) serveHttp({ handleMessage, port, banner: "🪨 prox shared daemon" }); -else serveStdio({ handleMessage, banner: "🪨 prox started (prox_list, prox_find, prox_poke, prox_launch, prox_close)" }); +else serveStdio({ handleMessage, banner: "🪨 prox started (prox_list, prox_find, prox_poke, prox_launch, prox_close, prox_dump)" }); diff --git a/slab/bin/prox-sigil-export b/slab/bin/prox-sigil-export new file mode 100755 index 0000000000..e9eb2ef964 --- /dev/null +++ b/slab/bin/prox-sigil-export @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source_root="$here/../menubar-swift/Sources/SlabMenubar" +cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/slab/prox-sigil-export" +binary="$cache_root/prox-sigil-export" +main="$here/prox-sigil-export-main.swift" + +mkdir -p "$cache_root" +if [ ! -x "$binary" ] || [ "$main" -nt "$binary" ] || \ + [ "$source_root/SigilRenderer.swift" -nt "$binary" ] || \ + [ "$source_root/SigilMesh.swift" -nt "$binary" ] || \ + [ "$source_root/SigilRockFrames.swift" -nt "$binary" ]; then + cp "$main" "$cache_root/main.swift" + xcrun swiftc -O \ + "$source_root/SigilRenderer.swift" \ + "$source_root/SigilMesh.swift" \ + "$source_root/SigilRockFrames.swift" \ + "$cache_root/main.swift" \ + -o "$binary" +fi +exec "$binary" "$@" diff --git a/slab/bin/prox-sigil-export-main.swift b/slab/bin/prox-sigil-export-main.swift new file mode 100644 index 0000000000..3b83f8e645 --- /dev/null +++ b/slab/bin/prox-sigil-export-main.swift @@ -0,0 +1,56 @@ +import AppKit +import ImageIO +import UniformTypeIdentifiers + +// SigilRenderer's live-session naming overload references these two types; +// the standalone exporter only needs their tiny surface and never calls it. +struct ClaudeSession { let sessionId: String } +enum Paths { static let loopboyConfig = "" } + +guard CommandLine.arguments.count >= 3, + let seed = UInt64(CommandLine.arguments[1], radix: 16) else { + fputs("usage: prox-sigil-export [dark|light]\n", stderr) + exit(2) +} +let bundle = URL(fileURLWithPath: CommandLine.arguments[2], isDirectory: true) +let dark = CommandLine.arguments.count < 4 || CommandLine.arguments[3] != "light" +let frames = SigilRockFrames.render( + seed: seed, dark: dark, + sunHx: -0.45, sunElevation: 0.72, sunIntensity: 0.8, + frameCount: 48, px: 160) +guard let first = frames.first else { + fputs("prox-sigil-export: rock renderer returned no frames\n", stderr) + exit(1) +} + +func writePNG(_ frame: CGImage, to url: URL) -> Bool { + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, UTType.png.identifier as CFString, 1, nil) else { return false } + CGImageDestinationAddImage(dest, frame, nil) + return CGImageDestinationFinalize(dest) +} + +func writeGIF(_ images: [CGImage], to url: URL) -> Bool { + guard let dest = CGImageDestinationCreateWithURL( + url as CFURL, UTType.gif.identifier as CFString, images.count, nil) else { return false } + CGImageDestinationSetProperties(dest, [ + kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0] + ] as CFDictionary) + let properties = [ + kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: 1.0 / 24.0] + ] as CFDictionary + for image in images { CGImageDestinationAddImage(dest, image, properties) } + return CGImageDestinationFinalize(dest) +} + +guard writeGIF(frames, to: bundle.appendingPathComponent("sigil.gif")), + writePNG(first, to: bundle.appendingPathComponent("sigil.png")) else { + fputs("prox-sigil-export: could not write image assets\n", stderr) + exit(1) +} +let icon = NSImage(cgImage: first, size: NSSize(width: 160, height: 160)) +guard NSWorkspace.shared.setIcon(icon, forFile: bundle.path, options: []) else { + fputs("prox-sigil-export: Finder rejected custom icon\n", stderr) + exit(1) +} +print(bundle.appendingPathComponent("sigil.gif").path)