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 +11 -19
- package/package.json +1 -1
- package/src/cli.js +42 -83
- package/src/server.js +12 -1
- package/src/session.js +67 -44
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
61
|
+
#### Step 3: Restart your AI coding tool
|
|
70
62
|
|
|
71
|
-
Restart Claude Code for the
|
|
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>] [--
|
|
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.
|
|
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`
|
|
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
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
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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: ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
31
|
-
if (!fs.existsSync(
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
73
|
-
if (
|
|
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');
|