Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Turn ideas into validated designs and specs through collaborative dialogue before any code is written
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/server.cjs
1const crypto = require('crypto');2const http = require('http');3const fs = require('fs');4const path = require('path');56// ========== WebSocket Protocol (RFC 6455) ==========78const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };9const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';1011function computeAcceptKey(clientKey) {12return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');13}1415function encodeFrame(opcode, payload) {16const fin = 0x80;17const len = payload.length;18let header;1920if (len < 126) {21header = Buffer.alloc(2);22header[0] = fin | opcode;23header[1] = len;24} else if (len < 65536) {25header = Buffer.alloc(4);26header[0] = fin | opcode;27header[1] = 126;28header.writeUInt16BE(len, 2);29} else {30header = Buffer.alloc(10);31header[0] = fin | opcode;32header[1] = 127;33header.writeBigUInt64BE(BigInt(len), 2);34}3536return Buffer.concat([header, payload]);37}3839function decodeFrame(buffer) {40if (buffer.length < 2) return null;4142const secondByte = buffer[1];43const opcode = buffer[0] & 0x0F;44const masked = (secondByte & 0x80) !== 0;45let payloadLen = secondByte & 0x7F;46let offset = 2;4748if (!masked) throw new Error('Client frames must be masked');4950if (payloadLen === 126) {51if (buffer.length < 4) return null;52payloadLen = buffer.readUInt16BE(2);53offset = 4;54} else if (payloadLen === 127) {55if (buffer.length < 10) return null;56payloadLen = Number(buffer.readBigUInt64BE(2));57offset = 10;58}5960const maskOffset = offset;61const dataOffset = offset + 4;62const totalLen = dataOffset + payloadLen;63if (buffer.length < totalLen) return null;6465const mask = buffer.slice(maskOffset, dataOffset);66const data = Buffer.alloc(payloadLen);67for (let i = 0; i < payloadLen; i++) {68data[i] = buffer[dataOffset + i] ^ mask[i % 4];69}7071return { opcode, payload: data, bytesConsumed: totalLen };72}7374// ========== Configuration ==========7576const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));77const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';78const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);79const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';80const CONTENT_DIR = path.join(SESSION_DIR, 'content');81const STATE_DIR = path.join(SESSION_DIR, 'state');82let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;8384const MIME_TYPES = {85'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',86'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',87'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'88};8990// ========== Templates and Constants ==========9192const WAITING_PAGE = `<!DOCTYPE html>93<html>94<head><meta charset="utf-8"><title>Brainstorm Companion</title>95<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }96h1 { color: #333; } p { color: #666; }</style>97</head>98<body><h1>Brainstorm Companion</h1>99<p>Waiting for the agent to push a screen...</p></body></html>`;100101const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');102const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');103const helperInjection = '<script>\n' + helperScript + '\n</script>';104105// ========== Helper Functions ==========106107function isFullDocument(html) {108const trimmed = html.trimStart().toLowerCase();109return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');110}111112function wrapInFrame(content) {113return frameTemplate.replace('<!-- CONTENT -->', content);114}115116function getNewestScreen() {117const files = fs.readdirSync(CONTENT_DIR)118.filter(f => f.endsWith('.html'))119.map(f => {120const fp = path.join(CONTENT_DIR, f);121return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };122})123.sort((a, b) => b.mtime - a.mtime);124return files.length > 0 ? files[0].path : null;125}126127// ========== HTTP Request Handler ==========128129function handleRequest(req, res) {130touchActivity();131if (req.method === 'GET' && req.url === '/') {132const screenFile = getNewestScreen();133let html = screenFile134? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))135: WAITING_PAGE;136137if (html.includes('</body>')) {138html = html.replace('</body>', helperInjection + '\n</body>');139} else {140html += helperInjection;141}142143res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });144res.end(html);145} else if (req.method === 'GET' && req.url.startsWith('/files/')) {146const fileName = req.url.slice(7);147const filePath = path.join(CONTENT_DIR, path.basename(fileName));148if (!fs.existsSync(filePath)) {149res.writeHead(404);150res.end('Not found');151return;152}153const ext = path.extname(filePath).toLowerCase();154const contentType = MIME_TYPES[ext] || 'application/octet-stream';155res.writeHead(200, { 'Content-Type': contentType });156res.end(fs.readFileSync(filePath));157} else {158res.writeHead(404);159res.end('Not found');160}161}162163// ========== WebSocket Connection Handling ==========164165const clients = new Set();166167function handleUpgrade(req, socket) {168const key = req.headers['sec-websocket-key'];169if (!key) { socket.destroy(); return; }170171const accept = computeAcceptKey(key);172socket.write(173'HTTP/1.1 101 Switching Protocols\r\n' +174'Upgrade: websocket\r\n' +175'Connection: Upgrade\r\n' +176'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'177);178179let buffer = Buffer.alloc(0);180clients.add(socket);181182socket.on('data', (chunk) => {183buffer = Buffer.concat([buffer, chunk]);184while (buffer.length > 0) {185let result;186try {187result = decodeFrame(buffer);188} catch (e) {189socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));190clients.delete(socket);191return;192}193if (!result) break;194buffer = buffer.slice(result.bytesConsumed);195196switch (result.opcode) {197case OPCODES.TEXT:198handleMessage(result.payload.toString());199break;200case OPCODES.CLOSE:201socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));202clients.delete(socket);203return;204case OPCODES.PING:205socket.write(encodeFrame(OPCODES.PONG, result.payload));206break;207case OPCODES.PONG:208break;209default: {210const closeBuf = Buffer.alloc(2);211closeBuf.writeUInt16BE(1003);212socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));213clients.delete(socket);214return;215}216}217}218});219220socket.on('close', () => clients.delete(socket));221socket.on('error', () => clients.delete(socket));222}223224function handleMessage(text) {225let event;226try {227event = JSON.parse(text);228} catch (e) {229console.error('Failed to parse WebSocket message:', e.message);230return;231}232touchActivity();233console.log(JSON.stringify({ source: 'user-event', ...event }));234if (event.choice) {235const eventsFile = path.join(STATE_DIR, 'events');236fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');237}238}239240function broadcast(msg) {241const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));242for (const socket of clients) {243try { socket.write(frame); } catch (e) { clients.delete(socket); }244}245}246247// ========== Activity Tracking ==========248249const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes250let lastActivity = Date.now();251252function touchActivity() {253lastActivity = Date.now();254}255256// ========== File Watching ==========257258const debounceTimers = new Map();259260// ========== Server Startup ==========261262function startServer() {263if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });264if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });265266// Track known files to distinguish new screens from updates.267// macOS fs.watch reports 'rename' for both new files and overwrites,268// so we can't rely on eventType alone.269const knownFiles = new Set(270fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html'))271);272273const server = http.createServer(handleRequest);274server.on('upgrade', handleUpgrade);275276const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {277if (!filename || !filename.endsWith('.html')) return;278279if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));280debounceTimers.set(filename, setTimeout(() => {281debounceTimers.delete(filename);282const filePath = path.join(CONTENT_DIR, filename);283284if (!fs.existsSync(filePath)) return; // file was deleted285touchActivity();286287if (!knownFiles.has(filename)) {288knownFiles.add(filename);289const eventsFile = path.join(STATE_DIR, 'events');290if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);291console.log(JSON.stringify({ type: 'screen-added', file: filePath }));292} else {293console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));294}295296broadcast({ type: 'reload' });297}, 100));298});299watcher.on('error', (err) => console.error('fs.watch error:', err.message));300301function shutdown(reason) {302console.log(JSON.stringify({ type: 'server-stopped', reason }));303const infoFile = path.join(STATE_DIR, 'server-info');304if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);305fs.writeFileSync(306path.join(STATE_DIR, 'server-stopped'),307JSON.stringify({ reason, timestamp: Date.now() }) + '\n'308);309watcher.close();310clearInterval(lifecycleCheck);311server.close(() => process.exit(0));312}313314function ownerAlive() {315if (!ownerPid) return true;316try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }317}318319// Check every 60s: exit if owner process died or idle for 30 minutes320const lifecycleCheck = setInterval(() => {321if (!ownerAlive()) shutdown('owner process exited');322else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');323}, 60 * 1000);324lifecycleCheck.unref();325326// Validate owner PID at startup. If it's already dead, the PID resolution327// was wrong (common on WSL, Tailscale SSH, and cross-user scenarios).328// Disable monitoring and rely on the idle timeout instead.329if (ownerPid) {330try { process.kill(ownerPid, 0); }331catch (e) {332if (e.code !== 'EPERM') {333console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' }));334ownerPid = null;335}336}337}338339server.listen(PORT, HOST, () => {340const info = JSON.stringify({341type: 'server-started', port: Number(PORT), host: HOST,342url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,343screen_dir: CONTENT_DIR, state_dir: STATE_DIR344});345console.log(info);346fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');347});348}349350if (require.main === module) {351startServer();352}353354module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };355