Separate brainstorm server content and state into peer directories

The session directory now contains two peers: content/ (HTML served to
the browser) and state/ (events, server-info, pid, log). Previously
all files shared a single directory, making server state and user
interaction data accessible over the /files/ HTTP route.

Also fixes stale test assertion ("Waiting for Claude" → "Waiting for
the agent").

Reported-By: 吉田仁
This commit is contained in:
Jesse Vincent
2026-03-24 11:07:59 -07:00
parent 9e6e077d33
commit 9e3ed213a0
6 changed files with 80 additions and 67 deletions

View File

@@ -76,7 +76,9 @@ function decodeFrame(buffer) {
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
const CONTENT_DIR = path.join(SESSION_DIR, 'content');
const STATE_DIR = path.join(SESSION_DIR, 'state');
const OWNER_PID = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
const MIME_TYPES = {
@@ -112,10 +114,10 @@ function wrapInFrame(content) {
}
function getNewestScreen() {
const files = fs.readdirSync(SCREEN_DIR)
const files = fs.readdirSync(CONTENT_DIR)
.filter(f => f.endsWith('.html'))
.map(f => {
const fp = path.join(SCREEN_DIR, f);
const fp = path.join(CONTENT_DIR, f);
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
})
.sort((a, b) => b.mtime - a.mtime);
@@ -142,7 +144,7 @@ function handleRequest(req, res) {
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));
const filePath = path.join(CONTENT_DIR, path.basename(fileName));
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end('Not found');
@@ -230,7 +232,7 @@ function handleMessage(text) {
touchActivity();
console.log(JSON.stringify({ source: 'user-event', ...event }));
if (event.choice) {
const eventsFile = path.join(SCREEN_DIR, '.events');
const eventsFile = path.join(STATE_DIR, 'events');
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
}
}
@@ -258,32 +260,33 @@ const debounceTimers = new Map();
// ========== Server Startup ==========
function startServer() {
if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });
if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
// Track known files to distinguish new screens from updates.
// macOS fs.watch reports 'rename' for both new files and overwrites,
// so we can't rely on eventType alone.
const knownFiles = new Set(
fs.readdirSync(SCREEN_DIR).filter(f => f.endsWith('.html'))
fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html'))
);
const server = http.createServer(handleRequest);
server.on('upgrade', handleUpgrade);
const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => {
const watcher = fs.watch(CONTENT_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);
const filePath = path.join(CONTENT_DIR, filename);
if (!fs.existsSync(filePath)) return; // file was deleted
touchActivity();
if (!knownFiles.has(filename)) {
knownFiles.add(filename);
const eventsFile = path.join(SCREEN_DIR, '.events');
const eventsFile = path.join(STATE_DIR, 'events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
} else {
@@ -297,10 +300,10 @@ function startServer() {
function shutdown(reason) {
console.log(JSON.stringify({ type: 'server-stopped', reason }));
const infoFile = path.join(SCREEN_DIR, '.server-info');
const infoFile = path.join(STATE_DIR, 'server-info');
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
fs.writeFileSync(
path.join(SCREEN_DIR, '.server-stopped'),
path.join(STATE_DIR, 'server-stopped'),
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
);
watcher.close();
@@ -324,10 +327,10 @@ function startServer() {
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
screen_dir: CONTENT_DIR, state_dir: STATE_DIR
});
console.log(info);
fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');
});
}

View File

@@ -78,16 +78,17 @@ fi
SESSION_ID="$$-$(date +%s)"
if [[ -n "$PROJECT_DIR" ]]; then
SCREEN_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
else
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
LOG_FILE="${SCREEN_DIR}/.server.log"
STATE_DIR="${SESSION_DIR}/state"
PID_FILE="${STATE_DIR}/server.pid"
LOG_FILE="${STATE_DIR}/server.log"
# Create fresh session directory
mkdir -p "$SCREEN_DIR"
# Create fresh session directory with content and state peers
mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"
# Kill any existing server
if [[ -f "$PID_FILE" ]]; then
@@ -115,13 +116,13 @@ esac
# Foreground mode for environments that reap detached/background processes.
if [[ "$FOREGROUND" == "true" ]]; then
echo "$$" > "$PID_FILE"
env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs
env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs
exit $?
fi
# Start server, capturing output to log file
# Use nohup to survive shell exit; disown to remove from job table
nohup env BRAINSTORM_DIR="$SCREEN_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs > "$LOG_FILE" 2>&1 &
nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
disown "$SERVER_PID" 2>/dev/null
echo "$SERVER_PID" > "$PID_FILE"

View File

@@ -1,19 +1,20 @@
#!/usr/bin/env bash
# Stop the brainstorm server and clean up
# Usage: stop-server.sh <screen_dir>
# Usage: stop-server.sh <session_dir>
#
# Kills the server process. Only deletes session directory if it's
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
# kept so mockups can be reviewed later.
SCREEN_DIR="$1"
SESSION_DIR="$1"
if [[ -z "$SCREEN_DIR" ]]; then
echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
if [[ -z "$SESSION_DIR" ]]; then
echo '{"error": "Usage: stop-server.sh <session_dir>"}'
exit 1
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
STATE_DIR="${SESSION_DIR}/state"
PID_FILE="${STATE_DIR}/server.pid"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
@@ -42,11 +43,11 @@ if [[ -f "$PID_FILE" ]]; then
exit 1
fi
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
rm -f "$PID_FILE" "${STATE_DIR}/server.log"
# Only delete ephemeral /tmp directories
if [[ "$SCREEN_DIR" == /tmp/* ]]; then
rm -rf "$SCREEN_DIR"
if [[ "$SESSION_DIR" == /tmp/* ]]; then
rm -rf "$SESSION_DIR"
fi
echo '{"status": "stopped"}'