superpowers-mcp 5.1.2 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +25 -14
  2. package/README.zh-TW.md +30 -14
  3. package/out/server.js +2 -2
  4. package/package.json +1 -1
  5. package/skills/brainstorming/SKILL.md +5 -10
  6. package/skills/brainstorming/scripts/frame-template.html +25 -26
  7. package/skills/brainstorming/scripts/helper.js +107 -35
  8. package/skills/brainstorming/scripts/server.cjs +377 -30
  9. package/skills/brainstorming/scripts/start-server.sh +70 -9
  10. package/skills/brainstorming/scripts/stop-server.sh +66 -2
  11. package/skills/brainstorming/spec-document-reviewer-prompt.md +1 -1
  12. package/skills/brainstorming/visual-companion.md +29 -25
  13. package/skills/dispatching-parallel-agents/SKILL.md +9 -6
  14. package/skills/executing-plans/SKILL.md +2 -2
  15. package/skills/finishing-a-development-branch/SKILL.md +2 -12
  16. package/skills/receiving-code-review/SKILL.md +2 -2
  17. package/skills/requesting-code-review/SKILL.md +2 -2
  18. package/skills/requesting-code-review/code-reviewer.md +15 -11
  19. package/skills/subagent-driven-development/SKILL.md +206 -67
  20. package/skills/subagent-driven-development/implementer-prompt.md +30 -4
  21. package/skills/subagent-driven-development/scripts/review-package +44 -0
  22. package/skills/subagent-driven-development/scripts/sdd-workspace +22 -0
  23. package/skills/subagent-driven-development/scripts/task-brief +40 -0
  24. package/skills/subagent-driven-development/task-reviewer-prompt.md +188 -0
  25. package/skills/systematic-debugging/SKILL.md +1 -1
  26. package/skills/test-driven-development/SKILL.md +2 -2
  27. package/skills/using-git-worktrees/SKILL.md +9 -22
  28. package/skills/using-superpowers/SKILL.md +17 -72
  29. package/skills/using-superpowers/references/antigravity-tools.md +23 -0
  30. package/skills/using-superpowers/references/codex-tools.md +1 -21
  31. package/skills/using-superpowers/references/pi-tools.md +16 -0
  32. package/skills/writing-plans/SKILL.md +22 -0
  33. package/skills/writing-plans/plan-document-reviewer-prompt.md +1 -1
  34. package/skills/writing-skills/SKILL.md +52 -18
  35. package/skills/writing-skills/anthropic-best-practices.md +91 -91
  36. package/skills/writing-skills/persuasion-principles.md +3 -3
  37. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +0 -25
  38. package/skills/subagent-driven-development/spec-reviewer-prompt.md +0 -61
@@ -7,6 +7,7 @@ const path = require('path');
7
7
 
8
8
  const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
9
9
  const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+ const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024;
10
11
 
11
12
  function computeAcceptKey(clientKey) {
12
13
  return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
@@ -53,7 +54,11 @@ function decodeFrame(buffer) {
53
54
  offset = 4;
54
55
  } else if (payloadLen === 127) {
55
56
  if (buffer.length < 10) return null;
56
- payloadLen = Number(buffer.readBigUInt64BE(2));
57
+ const extendedLen = buffer.readBigUInt64BE(2);
58
+ if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
59
+ throw new Error('WebSocket frame payload exceeds maximum allowed size');
60
+ }
61
+ payloadLen = Number(extendedLen);
57
62
  offset = 10;
58
63
  }
59
64
 
@@ -73,14 +78,74 @@ function decodeFrame(buffer) {
73
78
 
74
79
  // ========== Configuration ==========
75
80
 
76
- const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
81
+ const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
82
+ const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
83
+ // Prefer an explicit port, else the port this session last bound (so a restart
84
+ // reuses it and an already-open browser tab reconnects), else a random high port.
85
+ function preferredPort() {
86
+ if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
87
+ if (PORT_FILE) {
88
+ try {
89
+ const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
90
+ if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
91
+ } catch (e) { /* no prior port recorded */ }
92
+ }
93
+ return randomPort();
94
+ }
95
+ let PORT = preferredPort();
77
96
  const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
78
97
  const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
79
98
  const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
80
99
  const CONTENT_DIR = path.join(SESSION_DIR, 'content');
81
100
  const STATE_DIR = path.join(SESSION_DIR, 'state');
101
+ const SUPERPOWERS_VERSION = readSuperpowersVersion();
102
+ const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
103
+ const TELEMETRY_DISABLE_ENV_VARS = [
104
+ 'SUPERPOWERS_DISABLE_TELEMETRY',
105
+ 'DISABLE_TELEMETRY',
106
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'
107
+ ];
108
+ const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
82
109
  let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
83
110
 
111
+ // Per-session secret key. The companion is reachable by any local browser tab
112
+ // and, when bound to a non-loopback host, by any host that can route to it.
113
+ // The key authenticates the real client uniformly across loopback, tunnel, and
114
+ // remote binds — and defeats DNS rebinding — where a Host/Origin allowlist
115
+ // cannot. It rides the served URL as ?key= and is mirrored into a cookie on
116
+ // first load so same-origin subresources and the WebSocket carry it for free.
117
+ // Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
118
+ // same key and an already-open tab's cookie still validates.
119
+ const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
120
+ function generateToken() {
121
+ return crypto.randomBytes(32).toString('hex');
122
+ }
123
+
124
+ function chmodOwnerOnly(file) {
125
+ try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ }
126
+ }
127
+
128
+ function initialToken() {
129
+ if (process.env.BRAINSTORM_TOKEN) {
130
+ return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
131
+ }
132
+ if (TOKEN_FILE) {
133
+ try {
134
+ const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
135
+ if (/^[0-9a-f]{32,}$/i.test(t)) {
136
+ chmodOwnerOnly(TOKEN_FILE);
137
+ return { value: t, source: 'file' };
138
+ }
139
+ } catch (e) { /* no prior token recorded */ }
140
+ }
141
+ return { value: generateToken(), source: 'generated' };
142
+ }
143
+
144
+ const tokenInfo = initialToken();
145
+ let TOKEN = tokenInfo.value;
146
+ let tokenSource = tokenInfo.source;
147
+ let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
148
+
84
149
  const MIME_TYPES = {
85
150
  '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
86
151
  '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
@@ -89,14 +154,46 @@ const MIME_TYPES = {
89
154
 
90
155
  // ========== Templates and Constants ==========
91
156
 
92
- const WAITING_PAGE = `<!DOCTYPE html>
157
+ function waitingPage() {
158
+ return renderBranding(`<!DOCTYPE html>
93
159
  <html>
94
160
  <head><meta charset="utf-8"><title>Brainstorm Companion</title>
161
+ <style>
162
+ body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
163
+ h1 { color: #333; } p { color: #666; }
164
+ .brand { display: flex; align-items: center; min-width: 0; overflow: hidden; margin-bottom: 1.5rem; color: #666; font-size: 0.9rem; line-height: 1; }
165
+ .brand a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 0.5rem; min-width: 0; max-width: 100%; line-height: 1; }
166
+ .brand-copy { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1; transform: translateY(-1px); }
167
+ .brand-logo { display: block; height: 1em; width: auto; max-width: 180px; filter: invert(1); }
168
+ </style>
169
+ </head>
170
+ <body><!-- BRANDING --><h1>Brainstorm Companion</h1>
171
+ <p>Waiting for the agent to push a screen...</p></body></html>`);
172
+ }
173
+
174
+ const FORBIDDEN_PAGE = `<!DOCTYPE html>
175
+ <html>
176
+ <head><meta charset="utf-8"><title>Session key required</title>
95
177
  <style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
96
- h1 { color: #333; } p { color: #666; }</style>
178
+ h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1em 0.3em; border-radius: 4px; }</style>
97
179
  </head>
98
- <body><h1>Brainstorm Companion</h1>
99
- <p>Waiting for the agent to push a screen...</p></body></html>`;
180
+ <body><h1>Session key required</h1>
181
+ <p>This page needs the full URL your coding agent gave you, including the
182
+ <code>?key=&hellip;</code> part. Copy the complete URL and open it again.</p></body></html>`;
183
+
184
+ function bootstrapPage(key) {
185
+ const jsonKey = JSON.stringify(String(key));
186
+ return `<!DOCTYPE html>
187
+ <html>
188
+ <head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
189
+ <body>
190
+ <script>
191
+ try { sessionStorage.setItem('brainstorm-session-key', ${jsonKey}); } catch (e) {}
192
+ location.replace('/');
193
+ </script>
194
+ </body>
195
+ </html>`;
196
+ }
100
197
 
101
198
  const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
102
199
  const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
@@ -104,35 +201,191 @@ const helperInjection = '<script>\n' + helperScript + '\n</script>';
104
201
 
105
202
  // ========== Helper Functions ==========
106
203
 
204
+ function readSuperpowersVersion() {
205
+ const root = path.join(__dirname, '../../..');
206
+ const manifests = [
207
+ path.join(root, 'package.json'),
208
+ path.join(root, '.codex-plugin/plugin.json')
209
+ ];
210
+
211
+ for (const manifest of manifests) {
212
+ try {
213
+ const data = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
214
+ if (data.version) return String(data.version);
215
+ } catch (e) {
216
+ // Packaged Codex plugins omit package.json; try the next manifest.
217
+ }
218
+ }
219
+
220
+ return 'unknown';
221
+ }
222
+
223
+ function isTruthyEnv(value) {
224
+ if (!value) return false;
225
+ const normalized = String(value).trim().toLowerCase();
226
+ if (!normalized) return false;
227
+ return !['0', 'false', 'no', 'off'].includes(normalized);
228
+ }
229
+
230
+ function brandMarkup() {
231
+ const escHtml = (v) => String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
232
+ const version = escHtml(SUPERPOWERS_VERSION);
233
+ const text = SUPERPOWERS_TELEMETRY_DISABLED
234
+ ? 'Prime Radiant Superpowers v' + version
235
+ : 'Superpowers v' + version;
236
+ const logo = SUPERPOWERS_TELEMETRY_DISABLED
237
+ ? ''
238
+ : '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '?v=' + encodeURIComponent(SUPERPOWERS_VERSION) + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
239
+
240
+ return '<div class="brand"><a href="https://github.com/obra/superpowers">' + logo + '<span class="brand-copy">' + text + '</span></a></div>';
241
+ }
242
+
243
+ function renderBranding(html) {
244
+ return html.split('<!-- BRANDING -->').join(brandMarkup());
245
+ }
246
+
107
247
  function isFullDocument(html) {
108
248
  const trimmed = html.trimStart().toLowerCase();
109
249
  return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
110
250
  }
111
251
 
112
252
  function wrapInFrame(content) {
113
- return frameTemplate.replace('<!-- CONTENT -->', content);
253
+ return renderBranding(frameTemplate).replace('<!-- CONTENT -->', content);
114
254
  }
115
255
 
116
256
  function getNewestScreen() {
117
257
  const files = fs.readdirSync(CONTENT_DIR)
118
- .filter(f => f.endsWith('.html'))
258
+ .filter(f => !f.startsWith('.') && f.endsWith('.html'))
119
259
  .map(f => {
120
260
  const fp = path.join(CONTENT_DIR, f);
261
+ if (!isRegularFileInsideContentDir(fp)) return null;
121
262
  return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
122
263
  })
264
+ .filter(Boolean)
123
265
  .sort((a, b) => b.mtime - a.mtime);
124
266
  return files.length > 0 ? files[0].path : null;
125
267
  }
126
268
 
269
+ function urlHostForHttp(host) {
270
+ const h = String(host);
271
+ if (h.startsWith('[') && h.endsWith(']')) return h;
272
+ return h.includes(':') ? '[' + h + ']' : h;
273
+ }
274
+
275
+ function companionUrl() {
276
+ return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN;
277
+ }
278
+
279
+ function browserLauncherForPlatform(url, {
280
+ platform = process.platform,
281
+ osRelease = require('os').release(),
282
+ env = process.env
283
+ } = {}) {
284
+ const isWSL = platform === 'linux' && /microsoft/i.test(osRelease);
285
+ if (platform === 'darwin') return { bin: 'open', args: [url] };
286
+ if (platform === 'win32' || isWSL) {
287
+ return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
288
+ }
289
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] };
290
+ return null;
291
+ }
292
+
293
+ function isRegularFileInsideContentDir(filePath) {
294
+ let stat, realContentDir, realFilePath;
295
+ try {
296
+ stat = fs.lstatSync(filePath);
297
+ if (stat.isSymbolicLink()) return false;
298
+ if (!stat.isFile()) return false;
299
+ realContentDir = fs.realpathSync(CONTENT_DIR);
300
+ realFilePath = fs.realpathSync(filePath);
301
+ } catch (e) {
302
+ return false;
303
+ }
304
+ return realFilePath.startsWith(realContentDir + path.sep);
305
+ }
306
+
307
+ // ========== Authentication ==========
308
+
309
+ function timingSafeEqualStr(a, b) {
310
+ const ab = Buffer.from(String(a));
311
+ const bb = Buffer.from(String(b));
312
+ if (ab.length !== bb.length) return false;
313
+ return crypto.timingSafeEqual(ab, bb);
314
+ }
315
+
316
+ function parseCookies(header) {
317
+ const out = {};
318
+ if (!header) return out;
319
+ for (const part of header.split(';')) {
320
+ const eq = part.indexOf('=');
321
+ if (eq < 0) continue;
322
+ out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
323
+ }
324
+ return out;
325
+ }
326
+
327
+ // A request is authorized if it carries the session key as ?key= or as the
328
+ // session cookie. Both are compared in constant time.
329
+ function isAuthorized(req) {
330
+ const q = req.url.indexOf('?');
331
+ if (q >= 0) {
332
+ const params = new URLSearchParams(req.url.slice(q + 1));
333
+ if (params.has('key')) {
334
+ const key = params.get('key');
335
+ return Boolean(key && timingSafeEqualStr(key, TOKEN));
336
+ }
337
+ }
338
+ const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
339
+ if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
340
+ return false;
341
+ }
342
+
343
+ function securityHeaders(headers = {}) {
344
+ return {
345
+ 'Referrer-Policy': 'no-referrer',
346
+ 'Cache-Control': 'no-store',
347
+ 'X-Frame-Options': 'DENY',
348
+ 'Content-Security-Policy': "frame-ancestors 'none'",
349
+ 'Cross-Origin-Resource-Policy': 'same-origin',
350
+ ...headers
351
+ };
352
+ }
353
+
354
+ function isAllowedWebSocketOrigin(req) {
355
+ const origin = req.headers.origin;
356
+ if (!origin) return true;
357
+ const host = req.headers.host;
358
+ if (!host) return false;
359
+ return origin === 'http://' + host;
360
+ }
361
+
127
362
  // ========== HTTP Request Handler ==========
128
363
 
129
364
  function handleRequest(req, res) {
130
- touchActivity();
131
- if (req.method === 'GET' && req.url === '/') {
365
+ if (!isAuthorized(req)) {
366
+ res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
367
+ res.end(FORBIDDEN_PAGE);
368
+ return;
369
+ }
370
+ touchActivity(); // only authorized requests count as activity
371
+
372
+ // Mirror the key into a cookie so same-origin subresources (/files/*) can
373
+ // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
374
+ // WebSocket Origin check below is what blocks cross-origin localhost injection.
375
+ res.setHeader('Set-Cookie',
376
+ COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
377
+
378
+ const qIdx = req.url.indexOf('?');
379
+ const pathname = qIdx >= 0 ? req.url.slice(0, qIdx) : req.url;
380
+ const keyFromQuery = qIdx >= 0 ? new URLSearchParams(req.url.slice(qIdx + 1)).get('key') : null;
381
+ if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
382
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
383
+ res.end(bootstrapPage(TOKEN));
384
+ } else if (req.method === 'GET' && pathname === '/') {
132
385
  const screenFile = getNewestScreen();
133
386
  let html = screenFile
134
387
  ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
135
- : WAITING_PAGE;
388
+ : waitingPage();
136
389
 
137
390
  if (html.includes('</body>')) {
138
391
  html = html.replace('</body>', helperInjection + '\n</body>');
@@ -140,22 +393,24 @@ function handleRequest(req, res) {
140
393
  html += helperInjection;
141
394
  }
142
395
 
143
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
396
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
144
397
  res.end(html);
145
- } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
146
- const fileName = req.url.slice(7);
147
- const filePath = path.join(CONTENT_DIR, path.basename(fileName));
148
- if (!fs.existsSync(filePath)) {
149
- res.writeHead(404);
398
+ } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
399
+ const fileName = path.basename(pathname.slice(7));
400
+ const filePath = path.join(CONTENT_DIR, fileName);
401
+ // Reject empty/dotfile names and anything that isn't a regular file —
402
+ // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
403
+ if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
404
+ res.writeHead(404, securityHeaders());
150
405
  res.end('Not found');
151
406
  return;
152
407
  }
153
408
  const ext = path.extname(filePath).toLowerCase();
154
409
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
155
- res.writeHead(200, { 'Content-Type': contentType });
410
+ res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
156
411
  res.end(fs.readFileSync(filePath));
157
412
  } else {
158
- res.writeHead(404);
413
+ res.writeHead(404, securityHeaders());
159
414
  res.end('Not found');
160
415
  }
161
416
  }
@@ -165,6 +420,8 @@ function handleRequest(req, res) {
165
420
  const clients = new Set();
166
421
 
167
422
  function handleUpgrade(req, socket) {
423
+ if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
424
+
168
425
  const key = req.headers['sec-websocket-key'];
169
426
  if (!key) { socket.destroy(); return; }
170
427
 
@@ -231,7 +488,7 @@ function handleMessage(text) {
231
488
  }
232
489
  touchActivity();
233
490
  console.log(JSON.stringify({ source: 'user-event', ...event }));
234
- if (event.choice) {
491
+ if (event && event.choice) {
235
492
  const eventsFile = path.join(STATE_DIR, 'events');
236
493
  fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
237
494
  }
@@ -244,9 +501,44 @@ function broadcast(msg) {
244
501
  }
245
502
  }
246
503
 
504
+ // Best-effort: open the user's browser the first time a screen is actually ready
505
+ // to show. Skips when disabled, on a non-loopback (remote) bind, or when a
506
+ // browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD.
507
+ let browserOpened = false;
508
+ function maybeOpenBrowser() {
509
+ if (browserOpened) return;
510
+ browserOpened = true;
511
+ if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion
512
+ if (HOST !== '127.0.0.1' && HOST !== 'localhost') return;
513
+ if (clients.size > 0) return; // the user already opened it
514
+ const url = companionUrl(); // must carry the key or the gate 403s it
515
+ const cp = require('child_process');
516
+ // Operator-provided launcher: run as given (this env var is trusted operator input).
517
+ if (process.env.BRAINSTORM_OPEN_CMD) {
518
+ try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ }
519
+ return;
520
+ }
521
+ // Platform launchers: pass the URL as an argv element via execFile (no shell),
522
+ // so a url-host containing shell metacharacters can't inject a command.
523
+ const launcher = browserLauncherForPlatform(url);
524
+ if (!launcher) return; // headless: nothing to open
525
+ try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ }
526
+ }
527
+
247
528
  // ========== Activity Tracking ==========
248
529
 
249
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
530
+ // Idle timeout: shut down after this long with no activity. Default 4 hours;
531
+ // override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes).
532
+ const IDLE_TIMEOUT_MS = (() => {
533
+ const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS);
534
+ return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000;
535
+ })();
536
+ // How often the watchdog checks for owner-death / idleness. Configurable mainly
537
+ // so tests can run fast; production default is 60s.
538
+ const LIFECYCLE_CHECK_MS = (() => {
539
+ const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS);
540
+ return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000;
541
+ })();
250
542
  let lastActivity = Date.now();
251
543
 
252
544
  function touchActivity() {
@@ -267,14 +559,14 @@ function startServer() {
267
559
  // macOS fs.watch reports 'rename' for both new files and overwrites,
268
560
  // so we can't rely on eventType alone.
269
561
  const knownFiles = new Set(
270
- fs.readdirSync(CONTENT_DIR).filter(f => f.endsWith('.html'))
562
+ fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html'))
271
563
  );
272
564
 
273
565
  const server = http.createServer(handleRequest);
274
566
  server.on('upgrade', handleUpgrade);
275
567
 
276
568
  const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
277
- if (!filename || !filename.endsWith('.html')) return;
569
+ if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return;
278
570
 
279
571
  if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
280
572
  debounceTimers.set(filename, setTimeout(() => {
@@ -289,6 +581,7 @@ function startServer() {
289
581
  const eventsFile = path.join(STATE_DIR, 'events');
290
582
  if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
291
583
  console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
584
+ maybeOpenBrowser();
292
585
  } else {
293
586
  console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
294
587
  }
@@ -308,6 +601,11 @@ function startServer() {
308
601
  );
309
602
  watcher.close();
310
603
  clearInterval(lifecycleCheck);
604
+ // Close any upgraded WebSocket sockets so server.close() can complete and
605
+ // the process actually exits instead of lingering on an open connection.
606
+ for (const socket of clients) {
607
+ try { socket.destroy(); } catch (e) { /* already gone */ }
608
+ }
311
609
  server.close(() => process.exit(0));
312
610
  }
313
611
 
@@ -316,11 +614,11 @@ function startServer() {
316
614
  try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
317
615
  }
318
616
 
319
- // Check every 60s: exit if owner process died or idle for 30 minutes
617
+ // Periodically exit if the owner process died or we've been idle too long.
320
618
  const lifecycleCheck = setInterval(() => {
321
619
  if (!ownerAlive()) shutdown('owner process exited');
322
620
  else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
323
- }, 60 * 1000);
621
+ }, LIFECYCLE_CHECK_MS);
324
622
  lifecycleCheck.unref();
325
623
 
326
624
  // Validate owner PID at startup. If it's already dead, the PID resolution
@@ -336,19 +634,68 @@ function startServer() {
336
634
  }
337
635
  }
338
636
 
339
- server.listen(PORT, HOST, () => {
637
+ // If the preferred port is already taken (e.g. a previous server is still
638
+ // alive), fall back to a random port once instead of failing.
639
+ let triedFallback = false;
640
+
641
+ function onListen() {
642
+ // Cookie name keys on the ACTUAL bound port (may differ from the preferred
643
+ // one after an EADDRINUSE fallback) so it can't collide with another server's
644
+ // cookie in the shared localhost jar.
645
+ COOKIE_NAME = 'brainstorm-key-' + PORT;
646
+ // Record the bound port AND token so the next restart of this session reuses
647
+ // them — but ONLY when we got our preferred port. On a fallback we bound a
648
+ // *different* port because someone else holds the preferred one; persisting
649
+ // would overwrite the shared files and strand that other session's open tab.
650
+ if (PORT_FILE && !triedFallback) {
651
+ try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ }
652
+ if (TOKEN_FILE) {
653
+ try {
654
+ fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 });
655
+ chmodOwnerOnly(TOKEN_FILE);
656
+ } catch (e) { /* best effort */ }
657
+ }
658
+ }
340
659
  const info = JSON.stringify({
341
660
  type: 'server-started', port: Number(PORT), host: HOST,
342
- url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
343
- screen_dir: CONTENT_DIR, state_dir: STATE_DIR
661
+ url_host: URL_HOST, url: companionUrl(),
662
+ screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS
344
663
  });
345
664
  console.log(info);
346
- fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n');
665
+ // server-info embeds the key — keep it owner-only.
666
+ fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 });
667
+ }
668
+
669
+ server.on('error', (err) => {
670
+ if (err.code === 'EADDRINUSE' && !triedFallback) {
671
+ if (tokenSource === 'env') {
672
+ console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
673
+ process.exit(1);
674
+ }
675
+ triedFallback = true;
676
+ PORT = randomPort();
677
+ if (tokenSource === 'file') {
678
+ TOKEN = generateToken();
679
+ tokenSource = 'generated-fallback';
680
+ }
681
+ server.listen(PORT, HOST, onListen);
682
+ } else {
683
+ console.error('Server failed to bind:', err.message);
684
+ process.exit(1);
685
+ }
347
686
  });
687
+ server.listen(PORT, HOST, onListen);
348
688
  }
349
689
 
350
690
  if (require.main === module) {
351
691
  startServer();
352
692
  }
353
693
 
354
- module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
694
+ module.exports = {
695
+ computeAcceptKey,
696
+ encodeFrame,
697
+ decodeFrame,
698
+ browserLauncherForPlatform,
699
+ OPCODES,
700
+ MAX_FRAME_PAYLOAD_BYTES
701
+ };