可视化头脑风暴伴侣 —— 问题与变更目录
Section titled “可视化头脑风暴伴侣 —— 问题与变更目录”日期: 2026-06-09 状态: Analysis / triage. We are implementing these ourselves; the referenced community PRs are evidence and 引用 material, not code we intend to merge.
Purpose
Section titled “Purpose”A single place that captures every open issue and PR touching the visual
brainstorming companion (the 本地 服务器 in skills/brainstorming/scripts/),
distilled to the underlying problem and the change we’d make. Each item is
grounded against the 当前 code, not the PR author’s description.
Scope decisions (Jesse, 2026-06-09)
Section titled “Scope decisions (Jesse, 2026-06-09)”- Not vendoring Alpine.js. PR #1639 (interactive mockups via a vendored Alpine build) is dropped. See E3.
- E1 (terminal-vs-HTML hard gate) is a workshop item. We’ll 设计 it together; it is not specced here.
- E2 (storage location, #975/#977) is deferred for now.
- Remote serving is a first-class scenario. Superpowers is general-purpose;
users connect from remote (SSH tunnel, Tailscale,
--host 0.0.0.0). The 安全 fix MUST protect those users, not just loopback. Decision: a per-session secret key, not a Host allowlist. A Host allowlist only defends the loopback browser-confused-deputy; a direct remote 客户端 just sends the expectedHost, so the allowlist is theater for remote exposure. A secret key is the only thing that authenticates a 客户端 uniformly across loopback, tunnel, and direct-remote, and it also defeats DNS rebinding. See A1.
Component map
Section titled “Component map”| File | Role |
|---|---|
skills/brainstorming/scripts/server.cjs | Zero-dep HTTP + WebSocket 服务器 (RFC 6455 hand-rolled). Serves the newest screen, watches content/, records events to state/events. |
skills/brainstorming/scripts/helper.js | Injected into every page. WebSocket 客户端, click capture, window.brainstorm API. |
skills/brainstorming/scripts/frame-template.html | Frame (header, theme CSS, status dot, indicator bar) wrapped around content fragments. |
skills/brainstorming/scripts/start-server.sh | Launch wrapper. Session dir, host/url-host, owner-PID resolution, 平台 backgrounding. |
skills/brainstorming/scripts/stop-server.sh | Kills the 服务器 by PID file, cleans /tmp sessions. |
skills/brainstorming/visual-companion.md | Operator guide the agent 读取 when it accepts the companion. |
skills/brainstorming/SKILL.md | Where the companion is offered and the per-question decision lives. |
Disposition summary
Section titled “Disposition summary”| ID | Item | Source | Disposition |
|---|---|---|---|
| A1 | Per-session secret key on /, /files/*, and WS (supersedes Host allowlist) | issues #1014, PRs #1110/#1553 | Do — chosen approach |
| A2 | Host allowlist; browser WS Origin check | PRs #1110/#1553 | Host allowlist dropped; WS Origin check retained after auth for browser confused-deputy defense |
| A3 | Crash on null / non-object WS payload | PR #1504 | Do |
| A4 | Frame-length bound in decodeFrame | issue #1446 | Already fixed — verify/close |
| B1 | Dotfile screens served as content (._*.html) | PR #950 | Do |
| B2 | stop-server.sh kills reused/stale PID | PR #1703 | Do |
| B3 | WS 客户端 reconnect backoff + status indicator | PR #856 | Do |
| C1 | Idle timeout too short / not configurable; WS not closed on shutdown | issue #1237 (PR #1689) | Do |
| C2 | Server death is invisible to user/agent | issue #1237 (residual) | Do |
| D1 | Permanent opt-out of the companion | issue #892 | Deferred - not in PR #1720 |
| D2 | Free-text feedback from the browser | issue #957 | Deferred - not in PR #1720 |
| D3 | Auto-open the companion URL | PR #759 (#755) | Done in PR #1720 via --open |
| D4 | Light/dark contrast helpers in the frame | PR #1683 | Deferred - not in PR #1720 |
| E1 | Hard-gate terminal-vs-HTML per question | PR #1037 | Workshop |
| E2 | Move session state out of the working tree | issue #975 (PR #977) | Deferred |
| E3 | Vendor Alpine.js for interactive mockups | PR #1639 | Dropped |
| E4 | Shell-lint 警告 in start/stop scripts | PR #1677 | Opportunistic only |
A. Server 安全 加固 (server.cjs)
Section titled “A. Server 安全 加固 (server.cjs)”A1 — Per-session secret key (chosen approach)
Section titled “A1 — Per-session secret key (chosen approach)”Threat model. Two assets: confidentiality of the served screen (/) and
files (/files/*), and integrity of state/events — a WebSocket 客户端 with a
truthy choice writes there (server.cjs:243-246), and the agent 读取 it next
turn as the user’s selection, i.e. 提示词 injection into a live session with
full tool access. Reachers: with the default 127.0.0.1 bind, a malicious
page in the user’s browser (a confused deputy — runs attacker JS and can reach
loopback); with a remote bind (--host 0.0.0.0, tailnet/LAN), any host that can
route to the port, directly, with no same-origin policy in the way. Today
handleUpgrade (server.cjs:176) checks only Sec-WebSocket-Key, and
handleRequest (server.cjs:138) checks nothing — both are wide open.
Why a key, not a Host allowlist. A Host allowlist only defends the
loopback browser-deputy. A direct remote 客户端 just sends the expected Host
and forges/omits Origin, so the allowlist is theater for exactly the remote
case we must protect. A per-session secret authenticates the 客户端 uniformly
across loopback, SSH tunnel, and direct-remote, and it also kills DNS rebinding
(the rebound page neither knows the key nor receives the host-scoped cookie).
So the key supersedes A1/A2’s Host allowlist entirely — no BRAINSTORM_ALLOWED_HOSTS.
Design. Random token (crypto.randomBytes(32) hex), generated in
server.cjs at startup (overridable via BRAINSTORM_TOKEN for deterministic
tests):
- URL carries it as
?key=<token>. The 服务器 already buildsurlin itsserver-startedJSON (server.cjs:351) and writes it tostate/server-info— appending?key=there meansstart-server.sh(greps and prints that JSON) and the skill (hands the user that URL) need no change. - Cookie bootstrap. A valid
?keyon/setsbrainstorm-key-<port>=<token>; HttpOnly; SameSite=Strict; Path=/. The browser then auto-attaches it to same-origin subresources (/files/*) and the WebSocket handshake, so the agent can write any URL style and it works, andhelper.jsneeds no change. Cookie name is per-port to avoid the Jupyter multi-server collision (cookies aren’t port-scoped).SameSite=Strictis safe for CDN/Unsplash content — that cookie is host- scoped, so outbound CDN requests never carry it; SameSite only governs requests back to our origin, which are all same-site. - Auth gate = valid
?keyOR valid cookie (compared withcrypto.timingSafeEqual) on/,/files/*, and the WS upgrade. Missing/bad key → 友好 403 HTML page (“this page needs the full URL your coding agent gave you, including?key=…” — generic “coding agent”, not “Claude”, since this ships on Codex/Gemini/Copilot too). WS upgrade → destroy socket.
The query token is the 事实来源; the cookie is a convenience that never bears initial-auth load.
Blast radius. server.cjs (all logic). helper.js 可选 one-liner
(append ?key= from location.search to the WS URL as a cookie-blocked
回退). start-server.sh none. visual-companion.md doc note (URL now has
?key=; don’t strip it). Tests updated to pass the token.
A2 — Host allowlist dropped; browser WS Origin retained
Section titled “A2 — Host allowlist dropped; browser WS Origin retained”Subsumed by A1. The secret key closes the WS-injection vector (#1014), the
HTTP/WS DNS-rebinding read vector (PR #1553), and the cross-origin WS vector
(PR #1110) in one mechanism, and unlike an allowlist it actually protects the
remote-bind case. No BRAINSTORM_ALLOWED_HOSTS and no Host allowlist. The final
实施 still checks browser WebSocket Origin after session auth so a
cross-origin localhost tab cannot ride the companion cookie.
A3 — Server crashes on null / primitive WS payload
Section titled “A3 — Server crashes on null / primitive WS payload”Problem. handleMessage (server.cjs:233) does JSON.parse(text) then
if (event.choice) at server.cjs:243. A 客户端 that sends the 4-byte text
frame null yields event === null, and null.choice throws. The throw is
not caught — handleMessage is called from the socket.on('data') handler
(server.cjs:207) outside the try/catch, which only wraps decodeFrame. The
result is an uncaught exception and process exit. Any 本地 客户端 can kill the
server.
Change. Guard the access: if (event && event.choice). Minimal and exact —
JSON.parse can’t produce undefined, and primitives return undefined for
.choice without throwing, so only null is the live hazard. (Avoid the
broader fixes — a top-level try/catch or process.on('uncaughtException')
would mask other bugs.)
A4 — Frame-length bound in decodeFrame (adjacent)
Section titled “A4 — Frame-length bound in decodeFrame (adjacent)”Referenced by PR #1504 as #1446. The 当前 code already bounds extended
frame lengths: MAX_FRAME_PAYLOAD_BYTES = 10MB (server.cjs:10) is enforced at
server.cjs:58-67 before any Buffer.alloc. Action: verify #1446 against
当前 dev and close if already resolved, rather than re-implementing.
B. Server robustness / correctness
Section titled “B. Server robustness / correctness”B1 — macOS resource-fork dotfiles served as screen content
Section titled “B1 — macOS resource-fork dotfiles served as screen content”Problem. The newest-screen selector filters on f.endsWith('.html') only
(server.cjs:127-128). On macOS/ExFAT, ._screen.html resource-fork files pass
that filter and, being written alongside the real file, can sort newest — so the
browser gets binary metadata instead of the mockup. Four read sites share the
weak filter: getNewestScreen (server.cjs:127), knownFiles init
(server.cjs:279), the fs.watch handler (server.cjs:286), and the /files/
endpoint (server.cjs:154-156).
Change. Reject dotfiles (!f.startsWith('.')) at all four sites. Covers
._*, .DS_Store, etc.
B2 — stop-server.sh can kill a reused PID
Section titled “B2 — stop-server.sh can kill a reused PID”Problem. stop-server.sh 读取 the PID from state/server.pid
(stop-server.sh:20) and kills it (:23, escalating to -9 at :35)
without confirming the PID still belongs to our server. After a reboot or PID
wraparound the file can point at an unrelated process, which we’d then SIGKILL.
Change. Before signalling, verify ownership — the PID’s command is node
running our server.cjs, ideally matching this session. 如果 ownership can’t be
proven, fail closed (报告 stale_pid, don’t kill). Keep the 现有
stopped / not_running outputs for the real cases.
B3 — WebSocket 客户端: silent reconnect, stale “Connected”
Section titled “B3 — WebSocket 客户端: silent reconnect, stale “Connected””Problem. helper.js reconnects on a fixed 1s timer (helper.js:21-23),
has no onerror handler, never nulls ws on close, and never clears a pending
reconnect timer. The frame’s status element is hardcoded to “Connected” with the
dot pinned to var(--success) (frame-template.html:77,200). 当 the laptop
sleeps or the 服务器 restarts, the page shows “Connected” over a dead socket and
queues events with no feedback.
Change.
helper.js: exponential backoff (500ms → ×2 → cap 30s, reset on open);onerrordelegating toonclose;ws = nullon close;clearTimeoutbefore reconnecting.frame-template.html: drive the status dot from a--status-colorcustom property so JS can switch Connected (green) / Reconnecting (yellow) / Disconnected (red).
C. Lifecycle / timeout (issue #1237)
Section titled “C. Lifecycle / timeout (issue #1237)”C1 — Idle timeout too short, not configurable, WS keeps process alive
Section titled “C1 — Idle timeout too short, not configurable, WS keeps process alive”Problem. IDLE_TIMEOUT_MS is hardcoded to 30 minutes (server.cjs:258),
enforced by the 60s lifecycle check (server.cjs:329-332). A single brainstorm
question can sit longer than 30 min while the user thinks or steps away, so the
服务器 dies mid-session. Separately, shutdown() (server.cjs:310-321) calls
server.close() but never closes the upgraded sockets in clients
(server.cjs:174), so an open browser connection can keep the Node process
alive past shutdown.
Change.
- Raise the default to 4 hours and make it configurable:
--idle-timeout-minutesinstart-server.sh→ an env var →IDLE_TIMEOUT_MS, with validation against Node timer overflow. - Expose the effective timeout in the startup JSON /
state/server-info. - In
shutdown(), close every socket inclientsso the process actually exits.
C2 — Server death is invisible
Section titled “C2 — Server death is invisible”Problem. 当 the 服务器 exits it writes state/server-stopped and removes
state/server-info (server.cjs:312-317), and the skill is told to check
those files (visual-companion.md:108) — but it’s soft guidance the model skips,
and the browser just shows a generic “can’t be reached.” The user diagnoses it
manually; the agent keeps referring to a dead URL.
Change (two parts, independent of C1):
- Browser-facing tombstone. Leave something at the last-served URL that says
“this companion expired — ask Claude to 重启 it” instead of a connection
error. Options to weigh:
helper.jsrendering a banner when the socket stays down past backoff (works only while the page is 已加载), vs. a more involved approach that keeps a minimal responder alive to serve a tombstone page. - Harder skill check. Tighten
visual-companion.md/SKILL.mdso “checkserver-info/server-stoppedbefore referring to the URL or pushing a screen” is a 必需 step, not a note. Keep it lightweight — possibly a one-line helper the agent always runs.
D. Features
Section titled “D. Features”D1 — Permanent opt-out of the visual companion (issue #892)
Section titled “D1 — Permanent opt-out of the visual companion (issue #892)”Problem. The companion is offered as its own message every session
(SKILL.md:25,151-152). A user who never wants it pays that round-trip — and
HTML generation — every time. There’s no way to say “never offer this.”
Change. Before the offer step, the skill checks a user-level setting and skips the offer entirely when opt-out is set.
Design choice open. Mechanism isn’t settled:
- Env var (e.g.
SUPERPOWERS_VISUAL_COMPANION=off) the skill is told to read — simplest, matches what the issue asks for, lives in.zshrc. - A plugin-settings file (
.claude/superpowers.local.mdfrontmatter) — more structured, per-project capable, but heavier and project-scoped. - Reliability caveat from the issue: a separate “no-companion” skill competes on trigger words and isn’t reliable — rejected.
Pick the mechanism, then it’s a small SKILL.md change plus a documented knob.
D2 — Free-text feedback from the browser (issue #957)
Section titled “D2 — Free-text feedback from the browser (issue #957)”Problem. The 客户端 only captures clicks on [data-choice]
(helper.js:36-62). A user who wants to annotate a mockup (“wrong shade of
blue”) has to switch to the terminal, breaking the visual flow.
Change. 添加 a feedback <textarea> whose submit emits
{"type":"feedback","text":...,"timestamp":...} via the 现有
window.brainstorm.send path (helper.js:82-85).
Cross-cutting — 服务器 change required. handleMessage only persists events
when event.choice is truthy (server.cjs:243). A feedback event has no
choice, so today it would be logged but never written to state/events,
and the agent wouldn’t see it. The persistence condition must also accept
feedback events. Document the 新 event shape in visual-companion.md
(Browser Events Format, :247-259). Decide the submit trigger (button vs blur
vs both) and where the textarea renders (frame-level vs opt-in per screen).
D3 — Auto-open the companion URL (PR #759, issue #755)
Section titled “D3 — Auto-open the companion URL (PR #759, issue #755)”Problem. start-server.sh only prints the URL; the user opens it manually.
In WSL2 especially, people expect the browser to open.
Change. Best-effort opener after the server-started JSON is parsed:
Windows/WSL → rundll32.exe url.dll,FileProtocolHandler <url>, macOS → open,
Linux → xdg-open only when DISPLAY/WAYLAND_DISPLAY is set. Swallow
失败, never block startup, keep echoing the URL. Document in
visual-companion.md. (Consider an opt-out for headless/remote runs where
popping a browser is wrong — ties into D1’s 配置 mechanism.)
D4 — Light/dark contrast helpers (PR #1683)
Section titled “D4 — Light/dark contrast helpers (PR #1683)”Problem. Content fragments are wrapped in the OS-aware frame
(frame-template.html). In dark mode, quick mockups often use white inline
backgrounds while inheriting low-contrast frame text, making cards/panels hard
to read.
Change. 添加 .light-surface / .dark-surface helper classes plus a
conservative 回退 for common inline light backgrounds, and document them in
visual-companion.md’s CSS reference. Pure CSS in frame-template.html.
E. Workshop / deferred / dropped
Section titled “E. Workshop / deferred / dropped”E1 — Hard-gate terminal-vs-HTML per question (PR #1037) — WORKSHOP
Section titled “E1 — Hard-gate terminal-vs-HTML per question (PR #1037) — WORKSHOP”The soft guidance already exists: “decide per-question,” with browser-vs-terminal
tests in SKILL.md:156-161 and visual-companion.md:5-25. The complaint is that
the model renders HTML for purely textual content (A/B lists, clarifying
questions), wasting tokens and a turn. PR #1037 wraps the decision in a
<HARD-GATE>. Per Jesse, we’ll workshop the wording/mechanism together —
this is behavior-shaping skill content and not specced here.
E2 — Move session state out of the working tree (issue #975 / PR #977) — DEFERRED
Section titled “E2 — Move session state out of the working tree (issue #975 / PR #977) — DEFERRED”Today --project-dir writes session state to <project>/.superpowers/brainstorm/
(start-server.sh:80-84) and the skill tells the user to gitignore it
(visual-companion.md:58). The ask is a --state-dir / SUPERPOWERS_STATE_DIR
default outside the repo (XDG), keeping --project-dir as an alias.
Deferred by Jesse for now. Captured so it isn’t lost.
E3 — Vendor Alpine.js for interactive mockups (PR #1639) — DROPPED
Section titled “E3 — Vendor Alpine.js for interactive mockups (PR #1639) — DROPPED”Adds a vendored Alpine build so mockups can be interactive (tabs, accordions, forms) without hand-rolled JS. Dropped per Jesse — we are not taking on a vendored third-party 依赖 in the companion runtime. The underlying need (interactive mockups) is not being pursued via this route.
E4 — Shell-lint 警告 (PR #1677) — OPPORTUNISTIC
Section titled “E4 — Shell-lint 警告 (PR #1677) — OPPORTUNISTIC”SC2034 (and friends) in start-server.sh / stop-server.sh. Trivial; fold into
B2/C1/D3 when we’re already editing those scripts rather than as its own change.
Suggested grouping for 实施
Section titled “Suggested grouping for 实施”These cluster into a few coherent passes (each independently testable against
tests/brainstorm-server/):
- Security pass (IN PROGRESS, branch
brainstorm-companion-session-key) — A1 per-session key (supersedes A2) + A3 null-crash guard. Verify/close A4. Highest priority. - Lifecycle pass — C1 + C2 together (both touch
shutdown()and the server-death story). - Robustness pass — B1, B2, B3 (independent, small).
- Deferred feature pass - D1, D2, D4 are not part of PR #1720. D3 is
shipped through the
--openflow.
E1 is a separate workshop session. E2/E3 are 范围外 for this round.