diff --git a/commands/draft.md b/commands/draft.md index 22200aa..06e5620 100644 --- a/commands/draft.md +++ b/commands/draft.md @@ -50,7 +50,7 @@ If upcoming questions will involve visual content (UI layouts, architecture diag > "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. Want to try it? (Requires opening a local URL)" -Wait for the user's response. If they decline, proceed with text-only. If they accept, read `references/visual-companion.md` for the detailed guide. +Wait for the user's response. If they decline, proceed with text-only. If they accept, read `references/visual-companion.md` for the detailed guide. The server lives in `scripts/visual-companion/`. **Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?** - **Use the browser** for: mockups, wireframes, architecture diagrams, side-by-side visual comparisons diff --git a/context/blueprints/blueprint-frontier.md b/context/blueprints/blueprint-site.md similarity index 100% rename from context/blueprints/blueprint-frontier.md rename to context/blueprints/blueprint-site.md diff --git a/context/impl/impl-frontier.md b/context/impl/impl-site.md similarity index 100% rename from context/impl/impl-frontier.md rename to context/impl/impl-site.md diff --git a/references/visual-companion.md b/references/visual-companion.md index 0b53ec2..be24f58 100644 --- a/references/visual-companion.md +++ b/references/visual-companion.md @@ -30,23 +30,31 @@ The server watches a directory for HTML files and serves the newest one to the b ## Starting a Session -Use the superpowers visual companion server if available: +The visual companion server lives in `scripts/visual-companion/`. It's a zero-dependency Node.js server that watches a directory for HTML files and serves them with WebSocket live-reload. ```bash -# Start with project persistence -scripts/start-server.sh --project-dir $(pwd) +# Start with project persistence (mockups saved to .blueprint/companion/) +scripts/visual-companion/start-server.sh --project-dir $(pwd) + +# Returns JSON: {"type":"server-started","port":52341,"url":"http://localhost:52341","screen_dir":"/path/to/.blueprint/companion/12345-1706000000"} ``` -Save `screen_dir` from the response. Tell user to open the URL. +Save `screen_dir` from the response. Tell the user to open the URL. -If the visual companion server is not available, fall back to generating standalone HTML files that the user can open directly: +**Finding connection info:** The server writes startup JSON to `$SCREEN_DIR/.server-info`. If you launched in the background, read that file to get the URL and port. +**Platform notes:** +- **macOS/Linux:** Default mode works — the script backgrounds the server itself +- **Windows/Git Bash:** Auto-detects and uses foreground mode. Set `run_in_background: true` on the Bash tool call +- **Codex:** Auto-detects `CODEX_CI` and switches to foreground mode + +**Stopping:** ```bash -# Fallback: write HTML to a temp file and tell user to open it -# Write to .blueprint/brainstorm/ for persistence -mkdir -p .blueprint/brainstorm +scripts/visual-companion/stop-server.sh $SCREEN_DIR ``` +Persistent directories (`.blueprint/companion/`) are kept for later reference. Only `/tmp` sessions get cleaned up. + ## The Loop 1. **Write HTML** to a new file in `screen_dir` (or `.blueprint/brainstorm/`): diff --git a/scripts/visual-companion/frame-template.html b/scripts/visual-companion/frame-template.html new file mode 100644 index 0000000..7a81ddf --- /dev/null +++ b/scripts/visual-companion/frame-template.html @@ -0,0 +1,214 @@ + + + + + Blueprint Visual Companion + + + +
+

Blueprint Visual Companion

+
Connected
+
+ +
+
+ +
+
+ +
+ Click an option above, then return to the terminal +
+ + + diff --git a/scripts/visual-companion/helper.js b/scripts/visual-companion/helper.js new file mode 100644 index 0000000..4ef0533 --- /dev/null +++ b/scripts/visual-companion/helper.js @@ -0,0 +1,93 @@ +(function() { + const WS_URL = 'ws://' + window.location.host; + let ws = null; + let eventQueue = []; + + function connect() { + ws = new WebSocket(WS_URL); + + ws.onopen = () => { + eventQueue.forEach(e => ws.send(JSON.stringify(e))); + eventQueue = []; + }; + + ws.onmessage = (msg) => { + const data = JSON.parse(msg.data); + if (data.type === 'reload') { + window.location.reload(); + } + }; + + ws.onclose = () => { + setTimeout(connect, 1000); + }; + } + + function sendEvent(event) { + event.timestamp = Date.now(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } else { + eventQueue.push(event); + } + } + + function updateIndicator(container) { + const indicator = document.getElementById('indicator-text'); + if (!indicator) return; + const selected = container ? container.querySelectorAll('.selected') : []; + if (selected.length === 0) { + indicator.textContent = 'Click an option above, then return to the terminal'; + } else if (selected.length === 1) { + var label = selected[0].querySelector('h3, .content h3, .card-body h3'); + var labelText = (label ? label.textContent.trim() : selected[0].dataset.choice); + indicator.textContent = labelText + ' selected \u2014 return to terminal to continue'; + } else { + indicator.textContent = selected.length + ' selected \u2014 return to terminal to continue'; + } + } + + // Capture clicks on choice elements + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-choice]'); + if (!target) return; + + sendEvent({ + type: 'click', + text: target.textContent.trim(), + choice: target.dataset.choice, + id: target.id || null + }); + + // Update indicator bar (defer so toggleSelect runs first) + setTimeout(() => { + const container = target.closest('.options') || target.closest('.cards'); + updateIndicator(container); + }, 0); + }); + + // Frame UI: selection tracking + window.selectedChoice = null; + + window.toggleSelect = function(el) { + const container = el.closest('.options') || el.closest('.cards'); + const multi = container && container.dataset.multiselect !== undefined; + if (container && !multi) { + container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected')); + } + if (multi) { + el.classList.toggle('selected'); + } else { + el.classList.add('selected'); + } + window.selectedChoice = el.dataset.choice; + }; + + // Expose API for explicit use + window.brainstorm = { + send: sendEvent, + choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }) + }; + + connect(); +})(); diff --git a/scripts/visual-companion/server.cjs b/scripts/visual-companion/server.cjs new file mode 100644 index 0000000..dcc7658 --- /dev/null +++ b/scripts/visual-companion/server.cjs @@ -0,0 +1,334 @@ +const crypto = require('crypto'); +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// ========== WebSocket Protocol (RFC 6455) ========== + +const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A }; +const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64'); +} + +function encodeFrame(opcode, payload) { + const fin = 0x80; + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.alloc(2); + header[0] = fin | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = fin | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = fin | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +} + +function decodeFrame(buffer) { + if (buffer.length < 2) return null; + + const secondByte = buffer[1]; + const opcode = buffer[0] & 0x0F; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7F; + let offset = 2; + + if (!masked) throw new Error('Client frames must be masked'); + + if (payloadLen === 126) { + if (buffer.length < 4) return null; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return null; + payloadLen = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + + const maskOffset = offset; + const dataOffset = offset + 4; + const totalLen = dataOffset + payloadLen; + if (buffer.length < totalLen) return null; + + const mask = buffer.slice(maskOffset, dataOffset); + const data = Buffer.alloc(payloadLen); + for (let i = 0; i < payloadLen; i++) { + data[i] = buffer[dataOffset + i] ^ mask[i % 4]; + } + + return { opcode, payload: data, bytesConsumed: totalLen }; +} + +// ========== Configuration ========== + +const PORT = process.env.COMPANION_PORT || (49152 + Math.floor(Math.random() * 16383)); +const HOST = process.env.COMPANION_HOST || '127.0.0.1'; +const URL_HOST = process.env.COMPANION_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST); +const SCREEN_DIR = process.env.COMPANION_DIR || '/tmp/blueprint-companion'; +const OWNER_PID = process.env.COMPANION_OWNER_PID ? Number(process.env.COMPANION_OWNER_PID) : null; + +const MIME_TYPES = { + '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', + '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml' +}; + +// ========== Templates and Constants ========== + +const WAITING_PAGE = ` + +Blueprint Visual Companion + + +

Blueprint Visual Companion

+

Waiting for the agent to push a screen...

`; + +const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8'); +const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8'); +const helperInjection = ''; + +// ========== Helper Functions ========== + +function isFullDocument(html) { + const trimmed = html.trimStart().toLowerCase(); + return trimmed.startsWith('', content); +} + +function getNewestScreen() { + const files = fs.readdirSync(SCREEN_DIR) + .filter(f => f.endsWith('.html')) + .map(f => { + const fp = path.join(SCREEN_DIR, f); + return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; + }) + .sort((a, b) => b.mtime - a.mtime); + return files.length > 0 ? files[0].path : null; +} + +// ========== HTTP Request Handler ========== + +function handleRequest(req, res) { + touchActivity(); + if (req.method === 'GET' && req.url === '/') { + const screenFile = getNewestScreen(); + let html = screenFile + ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8')) + : WAITING_PAGE; + + if (html.includes('')) { + html = html.replace('', helperInjection + '\n'); + } else { + html += helperInjection; + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + } else if (req.method === 'GET' && req.url.startsWith('/files/')) { + const fileName = req.url.slice(7); + const filePath = path.join(SCREEN_DIR, path.basename(fileName)); + if (!fs.existsSync(filePath)) { + res.writeHead(404); + res.end('Not found'); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(fs.readFileSync(filePath)); + } else { + res.writeHead(404); + res.end('Not found'); + } +} + +// ========== WebSocket Connection Handling ========== + +const clients = new Set(); + +function handleUpgrade(req, socket) { + const key = req.headers['sec-websocket-key']; + if (!key) { socket.destroy(); return; } + + const accept = computeAcceptKey(key); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n' + ); + + let buffer = Buffer.alloc(0); + clients.add(socket); + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length > 0) { + let result; + try { + result = decodeFrame(buffer); + } catch (e) { + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + } + if (!result) break; + buffer = buffer.slice(result.bytesConsumed); + + switch (result.opcode) { + case OPCODES.TEXT: + handleMessage(result.payload.toString()); + break; + case OPCODES.CLOSE: + socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); + clients.delete(socket); + return; + case OPCODES.PING: + socket.write(encodeFrame(OPCODES.PONG, result.payload)); + break; + case OPCODES.PONG: + break; + default: { + const closeBuf = Buffer.alloc(2); + closeBuf.writeUInt16BE(1003); + socket.end(encodeFrame(OPCODES.CLOSE, closeBuf)); + clients.delete(socket); + return; + } + } + } + }); + + socket.on('close', () => clients.delete(socket)); + socket.on('error', () => clients.delete(socket)); +} + +function handleMessage(text) { + let event; + try { + event = JSON.parse(text); + } catch (e) { + console.error('Failed to parse WebSocket message:', e.message); + return; + } + touchActivity(); + console.log(JSON.stringify({ source: 'user-event', ...event })); + if (event.choice) { + const eventsFile = path.join(SCREEN_DIR, '.events'); + fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n'); + } +} + +function broadcast(msg) { + const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg))); + for (const socket of clients) { + try { socket.write(frame); } catch (e) { clients.delete(socket); } + } +} + +// ========== Activity Tracking ========== + +const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes +let lastActivity = Date.now(); + +function touchActivity() { + lastActivity = Date.now(); +} + +// ========== File Watching ========== + +const debounceTimers = new Map(); + +// ========== Server Startup ========== + +function startServer() { + if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true }); + + const knownFiles = new Set( + fs.readdirSync(SCREEN_DIR).filter(f => f.endsWith('.html')) + ); + + const server = http.createServer(handleRequest); + server.on('upgrade', handleUpgrade); + + const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => { + if (!filename || !filename.endsWith('.html')) return; + + if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename)); + debounceTimers.set(filename, setTimeout(() => { + debounceTimers.delete(filename); + const filePath = path.join(SCREEN_DIR, filename); + + if (!fs.existsSync(filePath)) return; + touchActivity(); + + if (!knownFiles.has(filename)) { + knownFiles.add(filename); + const eventsFile = path.join(SCREEN_DIR, '.events'); + if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); + console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + } else { + console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); + } + + broadcast({ type: 'reload' }); + }, 100)); + }); + watcher.on('error', (err) => console.error('fs.watch error:', err.message)); + + function shutdown(reason) { + console.log(JSON.stringify({ type: 'server-stopped', reason })); + const infoFile = path.join(SCREEN_DIR, '.server-info'); + if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile); + fs.writeFileSync( + path.join(SCREEN_DIR, '.server-stopped'), + JSON.stringify({ reason, timestamp: Date.now() }) + '\n' + ); + watcher.close(); + clearInterval(lifecycleCheck); + server.close(() => process.exit(0)); + } + + function ownerAlive() { + if (!OWNER_PID) return true; + try { process.kill(OWNER_PID, 0); return true; } catch (e) { return false; } + } + + const lifecycleCheck = setInterval(() => { + if (!ownerAlive()) shutdown('owner process exited'); + else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout'); + }, 60 * 1000); + lifecycleCheck.unref(); + + server.listen(PORT, HOST, () => { + const info = JSON.stringify({ + type: 'server-started', port: Number(PORT), host: HOST, + url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT, + screen_dir: SCREEN_DIR + }); + console.log(info); + fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n'); + }); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES }; diff --git a/scripts/visual-companion/start-server.sh b/scripts/visual-companion/start-server.sh new file mode 100755 index 0000000..8047cc3 --- /dev/null +++ b/scripts/visual-companion/start-server.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Start the Blueprint visual companion server and output connection info +# Usage: start-server.sh [--project-dir ] [--host ] [--url-host ] [--foreground] [--background] +# +# Starts server on a random high port, outputs JSON with URL. +# Each session gets its own directory to avoid conflicts. +# +# Options: +# --project-dir Store session files under /.blueprint/companion/ +# instead of /tmp. Files persist after server stops. +# --host Host/interface to bind (default: 127.0.0.1). +# Use 0.0.0.0 in remote/containerized environments. +# --url-host Hostname shown in returned URL JSON. +# --foreground Run server in the current terminal (no backgrounding). +# --background Force background mode (overrides Codex auto-foreground). + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Parse arguments +PROJECT_DIR="" +FOREGROUND="false" +FORCE_BACKGROUND="false" +BIND_HOST="127.0.0.1" +URL_HOST="" +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) + PROJECT_DIR="$2" + shift 2 + ;; + --host) + BIND_HOST="$2" + shift 2 + ;; + --url-host) + URL_HOST="$2" + shift 2 + ;; + --foreground|--no-daemon) + FOREGROUND="true" + shift + ;; + --background|--daemon) + FORCE_BACKGROUND="true" + shift + ;; + *) + echo "{\"error\": \"Unknown argument: $1\"}" + exit 1 + ;; + esac +done + +if [[ -z "$URL_HOST" ]]; then + if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then + URL_HOST="localhost" + else + URL_HOST="$BIND_HOST" + fi +fi + +# Some environments reap detached/background processes. Auto-foreground when detected. +if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + FOREGROUND="true" +fi + +# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected. +if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then + case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) FOREGROUND="true" ;; + esac + if [[ -n "${MSYSTEM:-}" ]]; then + FOREGROUND="true" + fi +fi + +# Generate unique session directory +SESSION_ID="$$-$(date +%s)" + +if [[ -n "$PROJECT_DIR" ]]; then + SCREEN_DIR="${PROJECT_DIR}/.blueprint/companion/${SESSION_ID}" +else + SCREEN_DIR="/tmp/blueprint-companion-${SESSION_ID}" +fi + +PID_FILE="${SCREEN_DIR}/.server.pid" +LOG_FILE="${SCREEN_DIR}/.server.log" + +# Create fresh session directory +mkdir -p "$SCREEN_DIR" + +# Kill any existing server +if [[ -f "$PID_FILE" ]]; then + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + rm -f "$PID_FILE" +fi + +cd "$SCRIPT_DIR" + +# Resolve the harness PID (grandparent of this script). +OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')" +if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then + OWNER_PID="$PPID" +fi + +# On Windows/MSYS2, the MSYS2 PID namespace is invisible to Node.js. +case "${OSTYPE:-}" in + msys*|cygwin*|mingw*) OWNER_PID="" ;; +esac + +# Foreground mode for environments that reap detached/background processes. +if [[ "$FOREGROUND" == "true" ]]; then + echo "$$" > "$PID_FILE" + env COMPANION_DIR="$SCREEN_DIR" COMPANION_HOST="$BIND_HOST" COMPANION_URL_HOST="$URL_HOST" COMPANION_OWNER_PID="$OWNER_PID" node server.cjs + exit $? +fi + +# Start server, capturing output to log file +nohup env COMPANION_DIR="$SCREEN_DIR" COMPANION_HOST="$BIND_HOST" COMPANION_URL_HOST="$URL_HOST" COMPANION_OWNER_PID="$OWNER_PID" node server.cjs > "$LOG_FILE" 2>&1 & +SERVER_PID=$! +disown "$SERVER_PID" 2>/dev/null +echo "$SERVER_PID" > "$PID_FILE" + +# Wait for server-started message (check log file) +for i in {1..50}; do + if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then + # Verify server is still alive after a short window + alive="true" + for _ in {1..20}; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + alive="false" + break + fi + sleep 0.1 + done + if [[ "$alive" != "true" ]]; then + echo "{\"error\": \"Server started but was killed. Retry with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}" + exit 1 + fi + grep "server-started" "$LOG_FILE" | head -1 + exit 0 + fi + sleep 0.1 +done + +# Timeout - server didn't start +echo '{"error": "Server failed to start within 5 seconds"}' +exit 1 diff --git a/scripts/visual-companion/stop-server.sh b/scripts/visual-companion/stop-server.sh new file mode 100755 index 0000000..114e594 --- /dev/null +++ b/scripts/visual-companion/stop-server.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Stop the Blueprint visual companion server and clean up +# Usage: stop-server.sh +# +# Kills the server process. Only deletes session directory if it's +# under /tmp (ephemeral). Persistent directories (.blueprint/) are +# kept so mockups can be reviewed later. + +SCREEN_DIR="$1" + +if [[ -z "$SCREEN_DIR" ]]; then + echo '{"error": "Usage: stop-server.sh "}' + exit 1 +fi + +PID_FILE="${SCREEN_DIR}/.server.pid" + +if [[ -f "$PID_FILE" ]]; then + pid=$(cat "$PID_FILE") + + # Try to stop gracefully, fallback to force if still alive + kill "$pid" 2>/dev/null || true + + # Wait for graceful shutdown (up to ~2s) + for i in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + + # If still running, escalate to SIGKILL + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + sleep 0.1 + fi + + if kill -0 "$pid" 2>/dev/null; then + echo '{"status": "failed", "error": "process still running"}' + exit 1 + fi + + rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log" + + # Only delete ephemeral /tmp directories + if [[ "$SCREEN_DIR" == /tmp/* ]]; then + rm -rf "$SCREEN_DIR" + fi + + echo '{"status": "stopped"}' +else + echo '{"status": "not_running"}' +fi