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
+
+
+
+
+
+
+
+
+ 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('