Move brainstorm server metadata to .meta/ subdirectory

Metadata files (.server-info, .events, .server.pid, .server.log,
.server-stopped) were stored in the same directory served over HTTP,
making them accessible via the /files/ route. They now live in a .meta/
subdirectory that is not web-accessible.

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

Reported-By: 吉田仁
This commit is contained in:
Jesse Vincent
2026-03-24 10:56:12 -07:00
parent a22122d57f
commit ab500dade6
6 changed files with 34 additions and 29 deletions

View File

@@ -13,6 +13,7 @@ The subagent review loop (dispatching a fresh agent to review plans/specs) doubl
### Bug Fixes
- **Brainstorm server metadata isolation** — metadata files (`.server-info`, `.events`, `.server.pid`, `.server.log`, `.server-stopped`) are now written to a `.meta/` subdirectory within the session directory, so they are not accessible over the web server's `/files/` route. (Reported by 吉田仁)
- **writing-skills** — corrected false claim that SKILL.md frontmatter supports "only two fields"; now says "two required fields" and links to the agentskills.io specification for all supported fields (PR #882 by @arittr)
### Codex App Compatibility

View File

@@ -77,6 +77,7 @@ const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() *
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 META_DIR = path.join(SCREEN_DIR, '.meta');
const OWNER_PID = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
const MIME_TYPES = {
@@ -230,7 +231,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(META_DIR, '.events');
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
}
}
@@ -259,6 +260,7 @@ const debounceTimers = new Map();
function startServer() {
if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });
if (!fs.existsSync(META_DIR)) fs.mkdirSync(META_DIR, { recursive: true });
// Track known files to distinguish new screens from updates.
// macOS fs.watch reports 'rename' for both new files and overwrites,
@@ -283,7 +285,7 @@ function startServer() {
if (!knownFiles.has(filename)) {
knownFiles.add(filename);
const eventsFile = path.join(SCREEN_DIR, '.events');
const eventsFile = path.join(META_DIR, '.events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
} else {
@@ -297,10 +299,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(META_DIR, '.server-info');
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
fs.writeFileSync(
path.join(SCREEN_DIR, '.server-stopped'),
path.join(META_DIR, '.server-stopped'),
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
);
watcher.close();
@@ -327,7 +329,7 @@ function startServer() {
screen_dir: SCREEN_DIR
});
console.log(info);
fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
fs.writeFileSync(path.join(META_DIR, '.server-info'), info + '\n');
});
}

View File

@@ -83,11 +83,12 @@ else
SCREEN_DIR="/tmp/brainstorm-${SESSION_ID}"
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
LOG_FILE="${SCREEN_DIR}/.server.log"
META_DIR="${SCREEN_DIR}/.meta"
PID_FILE="${META_DIR}/.server.pid"
LOG_FILE="${META_DIR}/.server.log"
# Create fresh session directory
mkdir -p "$SCREEN_DIR"
# Create fresh session directory and metadata subdirectory
mkdir -p "$SCREEN_DIR" "$META_DIR"
# Kill any existing server
if [[ -f "$PID_FILE" ]]; then

View File

@@ -13,7 +13,8 @@ if [[ -z "$SCREEN_DIR" ]]; then
exit 1
fi
PID_FILE="${SCREEN_DIR}/.server.pid"
META_DIR="${SCREEN_DIR}/.meta"
PID_FILE="${META_DIR}/.server.pid"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
@@ -42,7 +43,7 @@ if [[ -f "$PID_FILE" ]]; then
exit 1
fi
rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
rm -f "$PID_FILE" "${META_DIR}/.server.log"
# Only delete ephemeral /tmp directories
if [[ "$SCREEN_DIR" == /tmp/* ]]; then

View File

@@ -26,7 +26,7 @@ A question *about* a UI topic is not automatically a visual question. "What kind
## How It Works
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content, the user sees it in their browser and can click to select options. Selections are recorded to a `.events` file that you read on your next turn.
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content, the user sees it in their browser and can click to select options. Selections are recorded to `$SCREEN_DIR/.meta/.events` that you read on your next turn.
**Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, selection indicator, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page.
@@ -42,7 +42,7 @@ scripts/start-server.sh --project-dir /path/to/project
Save `screen_dir` from the response. Tell user to open the URL.
**Finding connection info:** The server writes its startup JSON to `$SCREEN_DIR/.server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. When using `--project-dir`, check `<project>/.superpowers/brainstorm/` for the session directory.
**Finding connection info:** The server writes its startup JSON to `$SCREEN_DIR/.meta/.server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. When using `--project-dir`, check `<project>/.superpowers/brainstorm/` for the session directory.
**Note:** Pass the project root as `--project-dir` so mockups persist in `.superpowers/brainstorm/` and survive server restarts. Without it, files go to `/tmp` and get cleaned up. Remind the user to add `.superpowers/` to `.gitignore` if it's not already there.
@@ -61,7 +61,7 @@ scripts/start-server.sh --project-dir /path/to/project
# across conversation turns.
scripts/start-server.sh --project-dir /path/to/project
```
When calling this via the Bash tool, set `run_in_background: true`. Then read `$SCREEN_DIR/.server-info` on the next turn to get the URL and port.
When calling this via the Bash tool, set `run_in_background: true`. Then read `$SCREEN_DIR/.meta/.server-info` on the next turn to get the URL and port.
**Codex:**
```bash
@@ -93,7 +93,7 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.
## The Loop
1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`:
- Before each write, check that `$SCREEN_DIR/.server-info` exists. If it doesn't (or `.server-stopped` exists), the server has shut down — restart it with `start-server.sh` before continuing. The server auto-exits after 30 minutes of inactivity.
- Before each write, check that `$SCREEN_DIR/.meta/.server-info` exists. If it doesn't (or `.meta/.server-stopped` exists), the server has shut down — restart it with `start-server.sh` before continuing. The server auto-exits after 30 minutes of inactivity.
- Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
- **Never reuse filenames** — each screen gets a fresh file
- Use Write tool — **never use cat/heredoc** (dumps noise into terminal)
@@ -105,9 +105,9 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.
- Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like."
3. **On your next turn** — after the user responds in the terminal:
- Read `$SCREEN_DIR/.events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines
- Read `$SCREEN_DIR/.meta/.events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines
- Merge with the user's terminal text to get the full picture
- The terminal message is the primary feedback; `.events` provides structured interaction data
- The terminal message is the primary feedback; `.meta/.events` provides structured interaction data
4. **Iterate or advance** — if feedback changes current screen, write a new file (e.g., `layout-v2.html`). Only move to the next question when the current step is validated.
@@ -244,7 +244,7 @@ The frame template provides these CSS classes for your content:
## Browser Events Format
When the user clicks options in the browser, their interactions are recorded to `$SCREEN_DIR/.events` (one JSON object per line). The file is cleared automatically when you push a new screen.
When the user clicks options in the browser, their interactions are recorded to `$SCREEN_DIR/.meta/.events` (one JSON object per line). The file is cleared automatically when you push a new screen.
```jsonl
{"type":"click","choice":"a","text":"Option A - Simple Layout","timestamp":1706000101}
@@ -254,7 +254,7 @@ When the user clicks options in the browser, their interactions are recorded to
The full event stream shows the user's exploration path — they may click multiple options before settling. The last `choice` event is typically the final selection, but the pattern of clicks can reveal hesitation or preferences worth asking about.
If `.events` doesn't exist, the user didn't interact with the browser — use only their terminal text.
If `.meta/.events` doesn't exist, the user didn't interact with the browser — use only their terminal text.
## Design Tips

View File

@@ -103,9 +103,9 @@ async function runTests() {
return Promise.resolve();
});
await test('writes .server-info file', () => {
const infoPath = path.join(TEST_DIR, '.server-info');
assert(fs.existsSync(infoPath), '.server-info should exist');
await test('writes .server-info file to .meta/', () => {
const infoPath = path.join(TEST_DIR, '.meta', '.server-info');
assert(fs.existsSync(infoPath), '.meta/.server-info should exist');
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8').trim());
assert.strictEqual(info.type, 'server-started');
assert.strictEqual(info.port, TEST_PORT);
@@ -118,7 +118,7 @@ async function runTests() {
await test('serves waiting page when no screens exist', async () => {
const res = await fetch(`http://localhost:${TEST_PORT}/`);
assert.strictEqual(res.status, 200);
assert(res.body.includes('Waiting for Claude'), 'Should show waiting message');
assert(res.body.includes('Waiting for the agent'), 'Should show waiting message');
});
await test('injects helper.js into waiting page', async () => {
@@ -206,9 +206,9 @@ async function runTests() {
ws.close();
});
await test('writes choice events to .events file', async () => {
await test('writes choice events to .meta/.events file', async () => {
// Clean up events from prior tests
const eventsFile = path.join(TEST_DIR, '.events');
const eventsFile = path.join(TEST_DIR, '.meta', '.events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
@@ -225,8 +225,8 @@ async function runTests() {
ws.close();
});
await test('does NOT write non-choice events to .events file', async () => {
const eventsFile = path.join(TEST_DIR, '.events');
await test('does NOT write non-choice events to .meta/.events file', async () => {
const eventsFile = path.join(TEST_DIR, '.meta', '.events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
@@ -347,9 +347,9 @@ async function runTests() {
ws.close();
});
await test('clears .events on new screen', async () => {
await test('clears .meta/.events on new screen', async () => {
// Create an .events file
const eventsFile = path.join(TEST_DIR, '.events');
const eventsFile = path.join(TEST_DIR, '.meta', '.events');
fs.writeFileSync(eventsFile, '{"choice":"a"}\n');
assert(fs.existsSync(eventsFile));