From bccc41dffe46e0bc1ef007b902ecd4aafdadcc93 Mon Sep 17 00:00:00 2001 From: Jesse Vincent Date: Tue, 9 Jun 2026 15:26:19 -0700 Subject: [PATCH] feat(brainstorm-server): opt-in auto-open of the browser on the first screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user approves the visual companion, open their browser automatically the first time a screen is actually ready to show — rather than at startup (just the waiting page) or making them open the URL by hand. Opt-in and gated on approval: off unless BRAINSTORM_OPEN is set (start-server.sh --open, which the agent passes only after the user agrees to use the companion). Even then it fires once, and is skipped if a browser is already connected, on a non-loopback/remote bind, or when headless. Launcher is the platform default (open / xdg-open / WSL cmd.exe) or BRAINSTORM_OPEN_CMD; best-effort, never fatal. lifecycle.test.js: opens once on the first screen when approved; does NOT open without approval. Closes #755 Refs #759 --- skills/brainstorming/scripts/server.cjs | 22 +++++++++++ skills/brainstorming/scripts/start-server.sh | 6 +++ tests/brainstorm-server/lifecycle.test.js | 39 ++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index 30b92b10..7e4e1b20 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -267,6 +267,27 @@ function broadcast(msg) { } } +// Best-effort: open the user's browser the first time a screen is actually ready +// to show. Skips when disabled, on a non-loopback (remote) bind, or when a +// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD. +let browserOpened = false; +function maybeOpenBrowser() { + if (browserOpened) return; + browserOpened = true; + if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion + if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; + if (clients.size > 0) return; // the user already opened it + const url = 'http://' + URL_HOST + ':' + PORT; + let cmd = process.env.BRAINSTORM_OPEN_CMD; + if (!cmd) { + if (process.platform === 'darwin') cmd = 'open'; + else if (/microsoft/i.test(require('os').release())) cmd = 'cmd.exe /c start ""'; // WSL → Windows browser + else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) cmd = 'xdg-open'; + else return; // headless: nothing to open + } + try { require('child_process').exec(cmd + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ } +} + // ========== Activity Tracking ========== // Idle timeout: shut down after this long with no activity. Default 4 hours; @@ -323,6 +344,7 @@ function startServer() { const eventsFile = path.join(STATE_DIR, 'events'); if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile); console.log(JSON.stringify({ type: 'screen-added', file: filePath })); + maybeOpenBrowser(); } else { console.log(JSON.stringify({ type: 'screen-updated', file: filePath })); } diff --git a/skills/brainstorming/scripts/start-server.sh b/skills/brainstorming/scripts/start-server.sh index 59d3ff2f..d4e5724a 100755 --- a/skills/brainstorming/scripts/start-server.sh +++ b/skills/brainstorming/scripts/start-server.sh @@ -12,6 +12,8 @@ # Use 0.0.0.0 in remote/containerized environments. # --url-host Hostname shown in returned URL JSON. # --idle-timeout-minutes Shut down after n minutes idle (default 240 = 4h). +# --open Auto-open the browser on the first screen (use only +# after the user approves the visual companion). # --foreground Run server in the current terminal (no backgrounding). # --background Force background mode (overrides Codex auto-foreground). @@ -42,6 +44,10 @@ while [[ $# -gt 0 ]]; do IDLE_TIMEOUT_MINUTES="$2" shift 2 ;; + --open) + export BRAINSTORM_OPEN=1 + shift + ;; --foreground|--no-daemon) FOREGROUND="true" shift diff --git a/tests/brainstorm-server/lifecycle.test.js b/tests/brainstorm-server/lifecycle.test.js index f264f32a..fbcb87c0 100644 --- a/tests/brainstorm-server/lifecycle.test.js +++ b/tests/brainstorm-server/lifecycle.test.js @@ -124,6 +124,45 @@ async function runTests() { assert(portB >= 49152, 'should fall back to a random high port'); }); + await test('auto-opens the browser once, on the first screen', async () => { + const dir = fs.mkdtempSync('/tmp/bs-open-'); + const marker = path.join(dir, 'opened.log'); + const openCmd = `sh -c 'echo "$0" >> ${marker}'`; // capture the launch instead of opening a browser + const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3417, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN: '1', BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } }); + let out = ''; srv.stdout.on('data', d => out += d.toString()); + for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50); + + // First screen, with no browser connected -> should auto-open. + fs.writeFileSync(path.join(dir, 'content', 'first.html'), '

First

'); + await sleep(700); + // Second screen -> must NOT open again. + fs.writeFileSync(path.join(dir, 'content', 'second.html'), '

Second

'); + await sleep(700); + + srv.kill(); await sleep(100); + const lines = fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean) : []; + fs.rmSync(dir, { recursive: true, force: true }); + + assert.strictEqual(lines.length, 1, 'should open exactly once'); + assert(lines[0].includes('3417'), `should open the server URL, got: ${lines[0]}`); + }); + + await test('does NOT auto-open unless approved (BRAINSTORM_OPEN unset)', async () => { + const dir = fs.mkdtempSync('/tmp/bs-open-'); + const marker = path.join(dir, 'opened.log'); + const openCmd = `sh -c 'echo "$0" >> ${marker}'`; + // BRAINSTORM_OPEN intentionally NOT set — auto-open must stay off. + const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3418, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } }); + let out = ''; srv.stdout.on('data', d => out += d.toString()); + for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50); + fs.writeFileSync(path.join(dir, 'content', 'first.html'), '

First

'); + await sleep(700); + srv.kill(); await sleep(100); + const opened = fs.existsSync(marker); + fs.rmSync(dir, { recursive: true, force: true }); + assert(!opened, 'must not open the browser without explicit approval'); + }); + console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`); if (failed > 0) process.exit(1); }