brainstorm-companion 1.0.0

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/src/session.js ADDED
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ class SessionManager {
7
+ constructor(projectDir) {
8
+ this.baseDir = projectDir
9
+ ? `${projectDir}/.superpowers/brainstorm/`
10
+ : `/tmp/brainstorm-companion/`;
11
+ }
12
+
13
+ create() {
14
+ const sessionId = `${process.pid}-${Date.now()}`;
15
+ const sessionDir = path.join(this.baseDir, sessionId);
16
+ fs.mkdirSync(sessionDir, { recursive: true });
17
+ return { sessionId, sessionDir };
18
+ }
19
+
20
+ getActive() {
21
+ if (!fs.existsSync(this.baseDir)) return null;
22
+
23
+ let entries;
24
+ try {
25
+ entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ // Collect session dirs with their mtime for sorting (most recent first)
31
+ const sessions = [];
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory()) continue;
34
+ const sessionDir = path.join(this.baseDir, entry.name);
35
+ try {
36
+ const stat = fs.statSync(sessionDir);
37
+ sessions.push({ name: entry.name, sessionDir, mtime: stat.mtimeMs });
38
+ } catch {
39
+ // skip unreadable dirs
40
+ }
41
+ }
42
+
43
+ // Sort most recent first
44
+ sessions.sort((a, b) => b.mtime - a.mtime);
45
+
46
+ for (const { name: sessionId, sessionDir } of sessions) {
47
+ const serverInfoPath = path.join(sessionDir, '.server-info');
48
+ if (!fs.existsSync(serverInfoPath)) continue;
49
+
50
+ let serverInfo;
51
+ try {
52
+ const raw = fs.readFileSync(serverInfoPath, 'utf8');
53
+ serverInfo = JSON.parse(raw);
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ // Verify server PID is alive
59
+ const pid = serverInfo.pid || serverInfo.serverPid;
60
+ if (pid) {
61
+ try {
62
+ process.kill(pid, 0);
63
+ } catch {
64
+ // PID is dead — stale session
65
+ continue;
66
+ }
67
+ } else {
68
+ // No PID in server-info — can't verify, skip
69
+ continue;
70
+ }
71
+
72
+ return { sessionId, sessionDir, serverInfo };
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ pushScreen(html, { slot, filename, label } = {}) {
79
+ const active = this.getActive();
80
+ if (!active) throw new Error('No active session found');
81
+ const { sessionDir } = active;
82
+
83
+ let filePath;
84
+ if (slot !== undefined) {
85
+ const slotDir = path.join(sessionDir, `slot-${slot}`);
86
+ fs.mkdirSync(slotDir, { recursive: true });
87
+ filePath = path.join(slotDir, 'current.html');
88
+ if (label !== undefined) {
89
+ fs.writeFileSync(path.join(slotDir, '.label'), String(label), 'utf8');
90
+ }
91
+ } else {
92
+ filePath = path.join(sessionDir, filename || `screen-${Date.now()}.html`);
93
+ }
94
+
95
+ fs.writeFileSync(filePath, html, 'utf8');
96
+ return { path: filePath };
97
+ }
98
+
99
+ readEvents() {
100
+ const active = this.getActive();
101
+ if (!active) return [];
102
+ const eventsPath = path.join(active.sessionDir, '.events');
103
+ if (!fs.existsSync(eventsPath)) return [];
104
+
105
+ try {
106
+ const raw = fs.readFileSync(eventsPath, 'utf8');
107
+ return raw
108
+ .split('\n')
109
+ .filter(line => line.trim())
110
+ .map(line => JSON.parse(line));
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ clearEvents() {
117
+ const active = this.getActive();
118
+ if (!active) return;
119
+ const eventsPath = path.join(active.sessionDir, '.events');
120
+ try {
121
+ fs.writeFileSync(eventsPath, '', 'utf8');
122
+ } catch {
123
+ // ignore if file doesn't exist
124
+ }
125
+ }
126
+
127
+ clearSlot(slot) {
128
+ const active = this.getActive();
129
+ if (!active) return;
130
+ const slotFile = path.join(active.sessionDir, `slot-${slot}`, 'current.html');
131
+ try {
132
+ fs.rmSync(slotFile);
133
+ } catch {
134
+ // ignore if not found
135
+ }
136
+ }
137
+
138
+ clearAll() {
139
+ const active = this.getActive();
140
+ if (!active) return;
141
+ const { sessionDir } = active;
142
+
143
+ // Remove top-level .html files
144
+ try {
145
+ const entries = fs.readdirSync(sessionDir, { withFileTypes: true });
146
+ for (const entry of entries) {
147
+ if (entry.isFile() && entry.name.endsWith('.html')) {
148
+ fs.rmSync(path.join(sessionDir, entry.name));
149
+ }
150
+ // Remove slot-*/current.html
151
+ if (entry.isDirectory() && entry.name.startsWith('slot-')) {
152
+ const slotCurrent = path.join(sessionDir, entry.name, 'current.html');
153
+ try {
154
+ fs.rmSync(slotCurrent);
155
+ } catch {
156
+ // ignore
157
+ }
158
+ }
159
+ }
160
+ } catch {
161
+ // ignore
162
+ }
163
+ }
164
+
165
+ getStatus() {
166
+ const active = this.getActive();
167
+ if (!active) return null;
168
+ const { sessionId, sessionDir, serverInfo } = active;
169
+
170
+ // Gather slots
171
+ const slots = [];
172
+ try {
173
+ const entries = fs.readdirSync(sessionDir, { withFileTypes: true });
174
+ for (const entry of entries) {
175
+ if (entry.isDirectory() && entry.name.startsWith('slot-')) {
176
+ const slotId = entry.name.replace(/^slot-/, '');
177
+ const slotDir = path.join(sessionDir, entry.name);
178
+ const labelPath = path.join(slotDir, '.label');
179
+ const hasContent = fs.existsSync(path.join(slotDir, 'current.html'));
180
+ let label = null;
181
+ if (fs.existsSync(labelPath)) {
182
+ try { label = fs.readFileSync(labelPath, 'utf8'); } catch { /* ignore */ }
183
+ }
184
+ slots.push({ slot: slotId, label, hasContent });
185
+ }
186
+ }
187
+ } catch {
188
+ // ignore
189
+ }
190
+
191
+ // Count events
192
+ const events = this.readEvents();
193
+ const eventCount = events.length;
194
+
195
+ // Uptime and URL from serverInfo
196
+ const uptime = serverInfo && serverInfo.startedAt
197
+ ? Date.now() - serverInfo.startedAt
198
+ : null;
199
+ const url = serverInfo && serverInfo.url ? serverInfo.url : null;
200
+
201
+ return { sessionId, sessionDir, slots, eventCount, uptime, url };
202
+ }
203
+
204
+ cleanup() {
205
+ const active = this.getActive();
206
+ if (!active) return;
207
+ try {
208
+ fs.rmSync(active.sessionDir, { recursive: true, force: true });
209
+ } catch {
210
+ // ignore
211
+ }
212
+ }
213
+ }
214
+
215
+ module.exports = { SessionManager };
@@ -0,0 +1,277 @@
1
+ (function() {
2
+ const WS_URL = 'ws://' + window.location.host;
3
+ let ws = null;
4
+ let eventQueue = [];
5
+ let currentView = 'side-by-side';
6
+ let activeSlot = null;
7
+ let preferred = null;
8
+ let slots = [];
9
+
10
+ // -------------------------------------------------------------------------
11
+ // WebSocket
12
+ // -------------------------------------------------------------------------
13
+
14
+ function connect() {
15
+ ws = new WebSocket(WS_URL);
16
+ ws.onopen = () => {
17
+ document.getElementById('status').textContent = 'Connected';
18
+ eventQueue.forEach(e => ws.send(JSON.stringify(e)));
19
+ eventQueue = [];
20
+ };
21
+ ws.onmessage = (msg) => {
22
+ let data;
23
+ try { data = JSON.parse(msg.data); } catch { return; }
24
+
25
+ if (data.type === 'reload') {
26
+ // Reload all iframes
27
+ reloadAllIframes();
28
+ } else if (data.type === 'slot-content') {
29
+ // Reload a specific slot iframe
30
+ reloadSlotIframe(data.slot);
31
+ } else if (data.type === 'slots-update') {
32
+ // Re-initialize slots
33
+ if (data.slots) initSlots(data.slots);
34
+ }
35
+ };
36
+ ws.onclose = () => {
37
+ document.getElementById('status').textContent = 'Reconnecting…';
38
+ setTimeout(connect, 1000);
39
+ };
40
+ ws.onerror = () => {
41
+ document.getElementById('status').textContent = 'Disconnected';
42
+ };
43
+ }
44
+
45
+ function sendEvent(event) {
46
+ event.timestamp = Date.now();
47
+ if (ws && ws.readyState === WebSocket.OPEN) {
48
+ ws.send(JSON.stringify(event));
49
+ } else {
50
+ eventQueue.push(event);
51
+ }
52
+ }
53
+
54
+ // -------------------------------------------------------------------------
55
+ // Iframe helpers
56
+ // -------------------------------------------------------------------------
57
+
58
+ function reloadAllIframes() {
59
+ slots.forEach(s => reloadSlotIframe(s.id));
60
+ }
61
+
62
+ function reloadSlotIframe(slotId) {
63
+ const iframe = document.getElementById('iframe-' + slotId);
64
+ if (iframe) {
65
+ iframe.src = '/slot/' + slotId + '?t=' + Date.now();
66
+ }
67
+ }
68
+
69
+ // -------------------------------------------------------------------------
70
+ // Slot initialization
71
+ // -------------------------------------------------------------------------
72
+
73
+ function initSlots(newSlots) {
74
+ slots = newSlots;
75
+ buildTabBar();
76
+ buildPanels();
77
+ buildPrefButtons();
78
+
79
+ if (slots.length > 0) {
80
+ if (!activeSlot || !slots.find(s => s.id === activeSlot)) {
81
+ activeSlot = slots[0].id;
82
+ }
83
+ applyView();
84
+ updateTabHighlight();
85
+ }
86
+ }
87
+
88
+ function buildTabBar() {
89
+ const tabBar = document.getElementById('tab-bar');
90
+ tabBar.innerHTML = '';
91
+ slots.forEach((slot, i) => {
92
+ const btn = document.createElement('button');
93
+ btn.className = 'tab' + (slot.id === activeSlot ? ' active' : '');
94
+ btn.dataset.slot = slot.id;
95
+ btn.innerHTML =
96
+ '<span class="slot-letter">' + slot.id.toUpperCase() + '</span>' +
97
+ (slot.label ? '<span class="slot-label">' + escapeHtml(slot.label) + '</span>' : '');
98
+ btn.title = 'Slot ' + slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '') + ' (key: ' + (i + 1) + ' or ' + slot.id + ')';
99
+ btn.addEventListener('click', () => switchSlot(slot.id));
100
+ tabBar.appendChild(btn);
101
+ });
102
+ }
103
+
104
+ function buildPanels() {
105
+ const panelsEl = document.getElementById('panels');
106
+ panelsEl.innerHTML = '';
107
+ slots.forEach(slot => {
108
+ const panel = document.createElement('div');
109
+ panel.className = 'panel';
110
+ panel.id = 'panel-' + slot.id;
111
+
112
+ const header = document.createElement('div');
113
+ header.className = 'panel-header';
114
+ header.textContent = 'Slot ' + slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '');
115
+
116
+ const iframe = document.createElement('iframe');
117
+ iframe.id = 'iframe-' + slot.id;
118
+ iframe.src = '/slot/' + slot.id;
119
+ iframe.title = 'Slot ' + slot.id.toUpperCase();
120
+
121
+ panel.appendChild(header);
122
+ panel.appendChild(iframe);
123
+ panelsEl.appendChild(panel);
124
+ });
125
+ }
126
+
127
+ function buildPrefButtons() {
128
+ const bar = document.getElementById('preference-bar');
129
+ // Remove old pref buttons (keep the label span and keyboard hint)
130
+ const oldBtns = bar.querySelectorAll('.pref-btn, .pref-result');
131
+ oldBtns.forEach(el => el.remove());
132
+
133
+ // Insert buttons after the label span
134
+ const label = bar.querySelector('span');
135
+ slots.forEach(slot => {
136
+ const btn = document.createElement('button');
137
+ btn.className = 'pref-btn' + (preferred === slot.id ? ' selected' : '');
138
+ btn.dataset.slot = slot.id;
139
+ btn.textContent = slot.id.toUpperCase() + (slot.label ? ' — ' + slot.label : '');
140
+ btn.addEventListener('click', () => setPreferred(slot.id));
141
+ label.after(btn);
142
+ label.parentNode.insertBefore(btn, label.nextSibling);
143
+ });
144
+ }
145
+
146
+ // -------------------------------------------------------------------------
147
+ // View / slot switching
148
+ // -------------------------------------------------------------------------
149
+
150
+ function switchSlot(slotId) {
151
+ activeSlot = slotId;
152
+ updateTabHighlight();
153
+ if (currentView === 'single') {
154
+ applyView();
155
+ }
156
+ sendEvent({ type: 'tab-switch', slot: slotId });
157
+ }
158
+
159
+ function switchToSlotByIndex(index) {
160
+ if (index >= 0 && index < slots.length) {
161
+ switchSlot(slots[index].id);
162
+ }
163
+ }
164
+
165
+ function updateTabHighlight() {
166
+ document.querySelectorAll('#tab-bar .tab').forEach(tab => {
167
+ tab.classList.toggle('active', tab.dataset.slot === activeSlot);
168
+ });
169
+ }
170
+
171
+ function setView(mode) {
172
+ currentView = mode;
173
+ document.querySelectorAll('.view-btn').forEach(btn => {
174
+ btn.classList.toggle('active', btn.dataset.view === mode);
175
+ });
176
+ applyView();
177
+ sendEvent({ type: 'view-change', mode });
178
+ }
179
+
180
+ function toggleView() {
181
+ setView(currentView === 'side-by-side' ? 'single' : 'side-by-side');
182
+ }
183
+
184
+ function applyView() {
185
+ if (currentView === 'side-by-side') {
186
+ document.querySelectorAll('#panels .panel').forEach(panel => {
187
+ panel.classList.remove('hidden');
188
+ });
189
+ } else {
190
+ // Single view: show only active slot
191
+ document.querySelectorAll('#panels .panel').forEach(panel => {
192
+ const isActive = panel.id === 'panel-' + activeSlot;
193
+ panel.classList.toggle('hidden', !isActive);
194
+ });
195
+ }
196
+ }
197
+
198
+ function setPreferred(slotId) {
199
+ preferred = slotId;
200
+ document.querySelectorAll('.pref-btn').forEach(btn => {
201
+ btn.classList.toggle('selected', btn.dataset.slot === slotId);
202
+ });
203
+
204
+ // Show result text
205
+ const bar = document.getElementById('preference-bar');
206
+ let resultEl = bar.querySelector('.pref-result');
207
+ if (!resultEl) {
208
+ resultEl = document.createElement('div');
209
+ resultEl.className = 'pref-result';
210
+ const hint = bar.querySelector('.keyboard-hint');
211
+ if (hint) bar.insertBefore(resultEl, hint);
212
+ else bar.appendChild(resultEl);
213
+ }
214
+ const slot = slots.find(s => s.id === slotId);
215
+ const label = slot && slot.label ? slot.label : 'Slot ' + slotId.toUpperCase();
216
+ resultEl.textContent = label + ' preferred — return to terminal to continue';
217
+
218
+ sendEvent({ type: 'preference', choice: slotId, label });
219
+ }
220
+
221
+ // -------------------------------------------------------------------------
222
+ // Keyboard shortcuts
223
+ // -------------------------------------------------------------------------
224
+
225
+ document.addEventListener('keydown', (e) => {
226
+ // Don't intercept when typing in inputs
227
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
228
+
229
+ if (e.key === 'Tab') {
230
+ e.preventDefault();
231
+ toggleView();
232
+ return;
233
+ }
234
+ if (e.key >= '1' && e.key <= '9') {
235
+ switchToSlotByIndex(parseInt(e.key, 10) - 1);
236
+ return;
237
+ }
238
+ const lower = e.key.toLowerCase();
239
+ if ('abcdefghijklmnopqrstuvwxyz'.includes(lower) && lower.length === 1) {
240
+ if (slots.find(s => s.id === lower)) {
241
+ switchSlot(lower);
242
+ }
243
+ }
244
+ });
245
+
246
+ // -------------------------------------------------------------------------
247
+ // View toggle buttons
248
+ // -------------------------------------------------------------------------
249
+
250
+ document.addEventListener('click', (e) => {
251
+ const btn = e.target.closest('.view-btn');
252
+ if (btn) setView(btn.dataset.view);
253
+ });
254
+
255
+ // -------------------------------------------------------------------------
256
+ // Utility
257
+ // -------------------------------------------------------------------------
258
+
259
+ function escapeHtml(str) {
260
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
261
+ }
262
+
263
+ // -------------------------------------------------------------------------
264
+ // Boot: fetch slots from /api/status, then connect WebSocket
265
+ // -------------------------------------------------------------------------
266
+
267
+ fetch('/api/status')
268
+ .then(r => r.json())
269
+ .then(data => {
270
+ if (data.slots && data.slots.length > 0) {
271
+ initSlots(data.slots);
272
+ }
273
+ })
274
+ .catch(() => {});
275
+
276
+ connect();
277
+ })();