brainstorm-companion 2.1.2 → 2.1.3

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.
package/README.md CHANGED
@@ -12,13 +12,13 @@ Zero dependencies. Node.js >= 18 only.
12
12
  npm install -g brainstorm-companion
13
13
  ```
14
14
 
15
- ### Claude Code Setup
15
+ ### MCP Setup (Claude Code, Cursor, Windsurf, or any MCP client)
16
16
 
17
17
  Two parts: the **MCP server** (gives the agent tools) and the **skill** (teaches the agent how to use them well).
18
18
 
19
19
  #### Step 1: MCP Server
20
20
 
21
- Add to `~/.claude/.mcp.json` (create the file if it doesn't exist):
21
+ **Claude Code:** Add to `~/.claude/.mcp.json` (create the file if it doesn't exist):
22
22
 
23
23
  ```json
24
24
  {
@@ -31,19 +31,11 @@ Add to `~/.claude/.mcp.json` (create the file if it doesn't exist):
31
31
  }
32
32
  ```
33
33
 
34
- This gives the agent 5 tools: `brainstorm_start_session`, `brainstorm_push_screen`, `brainstorm_read_events`, `brainstorm_clear_screen`, `brainstorm_stop_session`. Full usage docs are embedded in each tool description.
34
+ **Other MCP clients (Cursor, Windsurf, etc.):** Use the same config format your client expects. The MCP server command is `brainstorm-companion --mcp` (stdio JSON-RPC).
35
35
 
36
- **Alternative (no global install):** Use `npx` instead:
37
- ```json
38
- {
39
- "mcpServers": {
40
- "brainstorm": {
41
- "command": "npx",
42
- "args": ["brainstorm-companion@latest", "--mcp"]
43
- }
44
- }
45
- }
46
- ```
36
+ This gives the agent 5 tools with full usage docs embedded in each description.
37
+
38
+ **Alternative (no global install):** Use `npx` instead — replace `"command": "brainstorm-companion"` with `"command": "npx"` and `"args": ["brainstorm-companion@latest", "--mcp"]`.
47
39
 
48
40
  #### Step 2: Skill (optional but recommended)
49
41
 
@@ -66,9 +58,9 @@ mkdir -p .claude/skills
66
58
  cp "$(npm root -g)/brainstorm-companion/skill/"*.md .claude/skills/
67
59
  ```
68
60
 
69
- #### Step 3: Restart Claude Code
61
+ #### Step 3: Restart your AI coding tool
70
62
 
71
- Restart Claude Code for the MCP server and skill to take effect.
63
+ Restart Claude Code / Cursor / your MCP client for the server and skill to take effect.
72
64
 
73
65
  ---
74
66
 
@@ -360,10 +352,10 @@ Global Options:
360
352
  ### `start`
361
353
 
362
354
  ```
363
- brainstorm-companion start [--project-dir <path>] [--port <N>] [--host <H>] [--timeout <min>] [--foreground] [--no-open] [--reuse]
355
+ brainstorm-companion start [--project-dir <path>] [--port <N>] [--host <H>] [--timeout <min>] [--no-open] [--reuse]
364
356
  ```
365
357
 
366
- Always creates a fresh session with a clean slate. Stops any existing session automatically. Use `--reuse` to keep an existing session and its content. Use `--timeout 30` for auto-cleanup after 30 minutes idle.
358
+ Always creates a fresh session with a clean slate. Server runs in foreground — stays alive as long as the process runs. In Claude Code, use `run_in_background` so the server stays alive while you push content. Use `--reuse` to keep an existing session. Use `--timeout 30` for auto-cleanup.
367
359
 
368
360
  ### `push`
369
361
 
@@ -405,7 +397,7 @@ Shows Session ID, URL, uptime, event count, and active slots.
405
397
 
406
398
  ## How It Works
407
399
 
408
- 1. `start` checks for an existing active session reuses it if found, otherwise creates a new one with its own port and directory
400
+ 1. `start` creates a fresh session with its own port, directory, and server process (foreground stays alive as long as the process runs)
409
401
  2. `push` writes HTML files to the session directory; the file watcher detects changes and broadcasts reload to the browser via WebSocket
410
402
  3. The browser auto-reloads and renders content in a themed frame with click capture on `[data-choice]` elements
411
403
  4. Click events are sent over WebSocket to the server and appended to a `.events` JSONL file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainstorm-companion",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "AI-assisted visual brainstorming companion",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { parseArgs } = require('node:util');
4
- const { exec, spawn } = require('node:child_process');
4
+ const { exec } = require('node:child_process');
5
5
  const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const { SessionManager } = require('./session');
@@ -57,17 +57,20 @@ Key concepts:
57
57
 
58
58
  Start the brainstorm server and open a browser window.
59
59
 
60
- Always creates a fresh session with a clean slate — no leftover content from
61
- previous runs. Stops any existing session automatically.
60
+ Always creates a fresh session with a clean slate — no leftover content.
61
+ Stops any existing session automatically. Server runs in foreground (stays
62
+ alive as long as this process runs).
62
63
 
63
- Use --reuse to keep an existing session instead.
64
+ In Claude Code: run with run_in_background so it stays alive while you push content.
65
+ In terminal: run in one tab, push from another. Ctrl+C to stop.
66
+
67
+ Use --reuse to keep an existing session instead of starting fresh.
64
68
 
65
69
  Options:
66
70
  --project-dir <path> Session storage location (default: /tmp/brainstorm-companion/)
67
71
  --port <number> Bind to specific port (default: random ephemeral)
68
72
  --host <address> Bind address (default: 127.0.0.1)
69
73
  --timeout <minutes> Auto-stop after N minutes of inactivity (default: none)
70
- --foreground Run server in foreground (don't background)
71
74
  --no-open Don't auto-open browser
72
75
  --reuse Reuse existing session if one is running (keep its content)
73
76
 
@@ -206,23 +209,6 @@ function sleep(ms) {
206
209
  return new Promise(resolve => setTimeout(resolve, ms));
207
210
  }
208
211
 
209
- async function pollForServerInfo(sessionDir, timeoutMs = 5000, intervalMs = 100) {
210
- const serverInfoPath = path.join(sessionDir, '.server-info');
211
- const deadline = Date.now() + timeoutMs;
212
- while (Date.now() < deadline) {
213
- if (fs.existsSync(serverInfoPath)) {
214
- try {
215
- const raw = fs.readFileSync(serverInfoPath, 'utf8');
216
- const info = JSON.parse(raw);
217
- if (info.url) return info;
218
- } catch {
219
- // file may be partially written, retry
220
- }
221
- }
222
- await sleep(intervalMs);
223
- }
224
- return null;
225
- }
226
212
 
227
213
  function openBrowser(url) {
228
214
  const platform = process.platform;
@@ -265,7 +251,6 @@ async function start(argv) {
265
251
  'port': { type: 'string', default: '0' },
266
252
  'host': { type: 'string', default: '127.0.0.1' },
267
253
  'timeout': { type: 'string' },
268
- 'foreground': { type: 'boolean', default: false },
269
254
  'no-open': { type: 'boolean', default: false },
270
255
  'reuse': { type: 'boolean', default: false },
271
256
  },
@@ -277,7 +262,6 @@ async function start(argv) {
277
262
  const port = parseInt(values['port'], 10) || 0;
278
263
  const timeoutMin = values['timeout'] ? parseInt(values['timeout'], 10) : 0;
279
264
  const idleTimeoutMs = timeoutMin > 0 ? timeoutMin * 60 * 1000 : 0;
280
- const foreground = values['foreground'];
281
265
  const noOpen = values['no-open'];
282
266
  const reuse = values['reuse'];
283
267
 
@@ -310,71 +294,46 @@ async function start(argv) {
310
294
 
311
295
  const { sessionDir } = session.create();
312
296
 
313
- if (foreground) {
314
- // Run server in-process
315
- const { startServer } = require('./server');
316
- const instance = startServer({
317
- screenDir: sessionDir,
318
- host,
319
- port,
320
- ownerPid: process.pid,
321
- idleTimeoutMs,
322
- });
323
-
324
- instance.server.once('listening', () => {
325
- console.log(`Server started: ${instance.url}`);
326
- console.log(`Session ID: ${path.basename(sessionDir)}`);
327
- printNextSteps();
328
- if (!noOpen) {
329
- openBrowser(instance.url);
330
- }
331
- });
332
-
333
- instance.server.once('error', (err) => {
334
- console.error(`Server error: ${err.message}`);
335
- process.exit(1);
336
- });
337
-
338
- // Keep process alive
339
- process.on('SIGINT', () => {
340
- instance.shutdown('sigint');
341
- process.exit(0);
342
- });
343
- process.on('SIGTERM', () => {
344
- instance.shutdown('sigterm');
345
- process.exit(0);
346
- });
347
- } else {
348
- // Background the server
349
- const serverScript = path.join(__dirname, 'server.js');
350
- const child = spawn(process.execPath, [serverScript], {
351
- detached: true,
352
- stdio: 'ignore',
353
- env: {
354
- ...process.env,
355
- BRAINSTORM_DIR: sessionDir,
356
- BRAINSTORM_HOST: host,
357
- BRAINSTORM_PORT: String(port),
358
- BRAINSTORM_IDLE_TIMEOUT: String(idleTimeoutMs),
359
- },
360
- });
361
- child.unref();
297
+ // Run server in-process (foreground) — this is the only reliable mode
298
+ // across all environments (Claude Code, Codex, Docker, terminals).
299
+ // The calling process must stay alive (use run_in_background in Claude Code).
300
+ const { startServer } = require('./server');
301
+ const instance = startServer({
302
+ screenDir: sessionDir,
303
+ host,
304
+ port,
305
+ ownerPid: process.pid,
306
+ idleTimeoutMs,
307
+ });
362
308
 
363
- // Poll for .server-info
364
- const serverInfo = await pollForServerInfo(sessionDir, 5000, 100);
365
- if (!serverInfo) {
366
- console.error('Timed out waiting for server to start.');
367
- process.exit(1);
368
- }
309
+ instance.server.once('listening', () => {
310
+ // Write global pointer so push/events/stop find this session
311
+ SessionManager.writeActivePointer(sessionDir);
369
312
 
370
- console.log(`Server started: ${serverInfo.url}`);
313
+ console.log(`Server started: ${instance.url}`);
371
314
  console.log(`Session ID: ${path.basename(sessionDir)}`);
372
315
  printNextSteps();
373
-
374
316
  if (!noOpen) {
375
- openBrowser(serverInfo.url);
317
+ openBrowser(instance.url);
376
318
  }
377
- }
319
+ });
320
+
321
+ instance.server.once('error', (err) => {
322
+ console.error(`Server error: ${err.message}`);
323
+ process.exit(1);
324
+ });
325
+
326
+ // Clean up on exit
327
+ const cleanup = (reason) => {
328
+ SessionManager.clearActivePointer();
329
+ instance.shutdown(reason);
330
+ process.exit(0);
331
+ };
332
+ process.on('SIGINT', () => cleanup('sigint'));
333
+ process.on('SIGTERM', () => cleanup('sigterm'));
334
+ process.on('exit', () => {
335
+ SessionManager.clearActivePointer();
336
+ });
378
337
  }
379
338
 
380
339
  // ---------------------------------------------------------------------------
package/src/server.js CHANGED
@@ -55,6 +55,16 @@ function wrapInFrame(content) {
55
55
  return frameTemplate.replace('<!-- CONTENT -->', content);
56
56
  }
57
57
 
58
+ // Like wrapInFrame but strips the header and indicator bar (for iframes in comparison mode)
59
+ function wrapInEmbed(content) {
60
+ let html = frameTemplate.replace('<!-- CONTENT -->', content);
61
+ // Remove header
62
+ html = html.replace(/<div class="header">[\s\S]*?<\/div>\s*<div class="main">/, '<div class="main">');
63
+ // Remove indicator bar (the actual HTML element, not the CSS)
64
+ html = html.replace(/\s*<div class="indicator-bar" id="indicator-bar">[\s\S]*?<\/div>\s*(?=<\/body>)/, '\n');
65
+ return html;
66
+ }
67
+
58
68
  function getNewestScreen(screenDir) {
59
69
  let files;
60
70
  try {
@@ -256,7 +266,8 @@ function startServer(config = {}) {
256
266
  return;
257
267
  }
258
268
  const raw = fs.readFileSync(slotFile, 'utf-8');
259
- let html = isFullDocument(raw) ? raw : wrapInFrame(raw);
269
+ // Slots are shown in iframes inside comparison page — skip the header/indicator
270
+ let html = isFullDocument(raw) ? raw : wrapInEmbed(raw);
260
271
  const slotNeeds = detectLibraries(html);
261
272
  const slotCdnTags = buildInjections(slotNeeds);
262
273
  if (slotCdnTags && html.includes('</head>')) {
package/src/session.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const DEFAULT_BASE = path.join('/tmp', 'brainstorm-companion');
7
+ const ACTIVE_POINTER = path.join(DEFAULT_BASE, '.active');
7
8
 
8
9
  class SessionManager {
9
10
  constructor(projectDir, targetSessionId) {
@@ -20,32 +21,63 @@ class SessionManager {
20
21
  return { sessionId, sessionDir };
21
22
  }
22
23
 
23
- getActive(targetSessionId) {
24
- if (!fs.existsSync(this.baseDir)) return null;
24
+ // Write a global pointer so any command can find the active session
25
+ // regardless of --project-dir
26
+ static writeActivePointer(sessionDir) {
27
+ try {
28
+ fs.mkdirSync(path.dirname(ACTIVE_POINTER), { recursive: true });
29
+ fs.writeFileSync(ACTIVE_POINTER, sessionDir, 'utf8');
30
+ } catch { /* ignore */ }
31
+ }
25
32
 
26
- // If a specific session ID is requested, go directly to it
27
- if (targetSessionId) {
28
- const sessionDir = path.join(this.baseDir, targetSessionId);
33
+ static clearActivePointer() {
34
+ try { fs.rmSync(ACTIVE_POINTER); } catch { /* ignore */ }
35
+ }
36
+
37
+ // Read the global pointer — returns { sessionDir, serverInfo } or null
38
+ static readActivePointer() {
39
+ try {
40
+ if (!fs.existsSync(ACTIVE_POINTER)) return null;
41
+ const sessionDir = fs.readFileSync(ACTIVE_POINTER, 'utf8').trim();
29
42
  if (!fs.existsSync(sessionDir)) return null;
30
- const serverInfoPath = path.join(sessionDir, '.server-info');
31
- if (!fs.existsSync(serverInfoPath)) return null;
32
- let serverInfo;
33
- try {
34
- const raw = fs.readFileSync(serverInfoPath, 'utf8');
35
- serverInfo = JSON.parse(raw);
36
- } catch {
37
- return null;
38
- }
43
+ const infoPath = path.join(sessionDir, '.server-info');
44
+ if (!fs.existsSync(infoPath)) return null;
45
+ const serverInfo = JSON.parse(fs.readFileSync(infoPath, 'utf8'));
39
46
  const pid = serverInfo.pid || serverInfo.serverPid;
40
47
  if (pid) {
41
- try { process.kill(pid, 0); } catch { return null; }
48
+ try { process.kill(pid, 0); } catch { return null; } // dead
42
49
  } else {
43
50
  return null;
44
51
  }
45
- return { sessionId: targetSessionId, sessionDir, serverInfo };
52
+ const sessionId = path.basename(sessionDir);
53
+ return { sessionId, sessionDir, serverInfo };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ getActive(targetSessionId) {
60
+ // If a specific session ID is requested, search baseDir and pointer
61
+ if (targetSessionId) {
62
+ // Try baseDir first
63
+ const sessionDir = path.join(this.baseDir, targetSessionId);
64
+ if (fs.existsSync(sessionDir)) {
65
+ const result = this._checkSession(targetSessionId, sessionDir);
66
+ if (result) return result;
67
+ }
68
+ // Try global pointer
69
+ const pointer = SessionManager.readActivePointer();
70
+ if (pointer && pointer.sessionId === targetSessionId) return pointer;
71
+ return null;
46
72
  }
47
73
 
48
- // No specific session find most recent with live PID
74
+ // Try global pointer first (works regardless of --project-dir)
75
+ const pointer = SessionManager.readActivePointer();
76
+ if (pointer) return pointer;
77
+
78
+ // Fall back to scanning baseDir
79
+ if (!fs.existsSync(this.baseDir)) return null;
80
+
49
81
  let entries;
50
82
  try {
51
83
  entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
@@ -60,43 +92,34 @@ class SessionManager {
60
92
  try {
61
93
  const stat = fs.statSync(sessionDir);
62
94
  sessions.push({ name: entry.name, sessionDir, mtime: stat.mtimeMs });
63
- } catch {
64
- // skip unreadable dirs
65
- }
95
+ } catch { /* skip */ }
66
96
  }
67
97
 
68
- // Sort most recent first
69
98
  sessions.sort((a, b) => b.mtime - a.mtime);
70
99
 
71
100
  for (const { name: sessionId, sessionDir } of sessions) {
72
- const serverInfoPath = path.join(sessionDir, '.server-info');
73
- if (!fs.existsSync(serverInfoPath)) continue;
74
-
75
- let serverInfo;
76
- try {
77
- const raw = fs.readFileSync(serverInfoPath, 'utf8');
78
- serverInfo = JSON.parse(raw);
79
- } catch {
80
- continue;
81
- }
82
-
83
- const pid = serverInfo.pid || serverInfo.serverPid;
84
- if (pid) {
85
- try {
86
- process.kill(pid, 0);
87
- } catch {
88
- continue;
89
- }
90
- } else {
91
- continue;
92
- }
93
-
94
- return { sessionId, sessionDir, serverInfo };
101
+ const result = this._checkSession(sessionId, sessionDir);
102
+ if (result) return result;
95
103
  }
96
104
 
97
105
  return null;
98
106
  }
99
107
 
108
+ _checkSession(sessionId, sessionDir) {
109
+ const serverInfoPath = path.join(sessionDir, '.server-info');
110
+ if (!fs.existsSync(serverInfoPath)) return null;
111
+ let serverInfo;
112
+ try {
113
+ serverInfo = JSON.parse(fs.readFileSync(serverInfoPath, 'utf8'));
114
+ } catch {
115
+ return null;
116
+ }
117
+ const pid = serverInfo.pid || serverInfo.serverPid;
118
+ if (!pid) return null;
119
+ try { process.kill(pid, 0); } catch { return null; }
120
+ return { sessionId, sessionDir, serverInfo };
121
+ }
122
+
100
123
  pushScreen(html, { slot, filename, label } = {}) {
101
124
  const active = this.getActive(this.targetSessionId);
102
125
  if (!active) throw new Error('No active session found');