superpowers-mcp 6.2.3 → 6.2.4

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.ja.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-6.2.3-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
5
+ [![Version](https://img.shields.io/badge/version-6.2.4-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
 
8
8
  このドキュメントは、オリジナルの Superpowers スキルライブラリを独立した MCP Toolpack にパッケージ化するための情報と使用手順をまとめたものです。
@@ -128,6 +128,14 @@
128
128
 
129
129
  ## 🆕 最近の更新
130
130
 
131
+ ### v6.2.4(最新)
132
+
133
+ - **上流に合わせた brainstorm セッションの永続化**:`--project-dir` 指定時、companion はセッションキーを `.superpowers/brainstorm/.last-token`(オーナーのみ読み取り可、.gitignore 済み)に保存し、`.last-port` と並んで再起動後も再利用します——開いたままのブラウザタブは再起動後も接続を維持し、URL の再共有は不要です。一時 `/tmp` セッションでは従来どおり起動ごとにキーをローテーションします。明示的な `BRAINSTORM_TOKEN` 環境変数は常に優先され、ファイルには書き込まれません。強制的にローテーションするには、サーバー停止後に `.last-token` を削除してください。
134
+ - **トークンファイル読み取り経路の強化**(`readPrivateFile`):シンボリックリンクや複数リンクの `.last-token` は拒否され、セッションキーとして採用されなくなります。読み取りは `O_NOFOLLOW` 付き fd 経由で行い、identity を再検証し 0600 に締め付けます——既に強化済みの書き込み経路との非対称性を解消しました(独立したセキュリティレビューで発見)。
135
+ - **診断性**:トークンファイルの書き込み失敗時に `Failed to write private token file:` をログ出力し、起動ごとのローテーションへの静かな縮退を防ぎます。
136
+ - **start-server.ps1 の環境衛生**:`--project-dir` なしの一時起動で、呼び出し元 pwsh セッションに残ったプロジェクトキー/ポートを継承しなくなります。
137
+ - **テスト**:companion スイートは 31 アサーションに——再起動をまたぐキー永続化、事前シード済みファイルの尊重、シンボリックリンクされたトークンファイルの拒否、トークンファイルなしでのローテーション維持。テスト後処理は障害安全(try/finally)。PowerShell スイートは `.last-token` が提供キーと一致することを検証します。
138
+
131
139
  ### v6.2.3(最新)
132
140
 
133
141
  - **Brainstorm Visual Companion の強化(`server.cjs`)**:ローカル loopback 限定の HTTP+WebSocket サーバーがファイルシステムのレースに耐性を持つようになりました(content ディレクトリの削除や画面ファイルの読み取り中の消失は待機ページ / 404 へフォールバック)。watcher はディレクトリの削除・再作成後に自己修復します(Linux inotify + macOS FSEvents)。WebSocket ハンドシェイクは RFC 6455 に基づき検証され、制御フレームは 125 バイト、idle/partial-frame に期限を設定し、接続上限時は最古の接続を破棄します。nonce CSP、起動ごとのキー更新、画面・スキル・イベントのサイズ上限、private state ファイルを適用しました。
package/README.ko.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-6.2.3-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
5
+ [![Version](https://img.shields.io/badge/version-6.2.4-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
 
8
8
  이 문서는 원본 Superpowers 스킬 라이브러리를 독립적인 MCP Toolpack으로 패키징하기 위한 정보와 사용 지침을 요약한 것입니다.
@@ -128,6 +128,14 @@
128
128
 
129
129
  ## 🆕 최근 업데이트
130
130
 
131
+ ### v6.2.4 (최신)
132
+
133
+ - **업스트림 정렬 — brainstorm 세션 영속화**: `--project-dir` 사용 시 companion이 세션 키를 `.superpowers/brainstorm/.last-token`(소유자 전용, .gitignore 적용)에 저장하고 `.last-port`와 함께 재시작 후에도 재사용합니다 — 이미 열린 브라우저 탭은 재시작 후에도 연결이 유지되며 URL을 다시 공유할 필요가 없습니다. 임시 `/tmp` 세션은 기존처럼 호출마다 키를 교체하며, 명시적 `BRAINSTORM_TOKEN` 환경 변수는 항상 우선하고 파일에 기록되지 않습니다. 강제 교체를 원하면 서버 중지 후 `.last-token`을 삭제하세요.
134
+ - **토큰 파일 읽기 경로 강화** (`readPrivateFile`): 심볼릭 링크 또는 다중 링크 `.last-token`은 거부되어 세션 키로 채택되지 않습니다. 읽기는 `O_NOFOLLOW` fd를 통해 수행되고 identity를 재검증하며 0600으로 강화됩니다 — 이미 강화된 쓰기 경로와의 비대칭을 해소했습니다(독립 보안 리뷰에서 발견).
135
+ - **진단 가능성**: 토큰 파일 쓰기 실패 시 `Failed to write private token file:`을 로그로 남겨 조용한 키 교체로의 퇴화를 방지합니다.
136
+ - **start-server.ps1 환경 위생**: `--project-dir` 없는 임시 실행에서 호출한 pwsh 세션의 잔여 프로젝트 키/포트를 상속하지 않습니다.
137
+ - **테스트**: companion 스위트가 31개 assertion으로 — 재시작 간 키 영속화, 사전 시드 파일 준수, 심볼릭 링크 토큰 파일 거부, 토큰 파일 없이도 키 교체 유지. 테스트 정리는 실패 안전(try/finally). PowerShell 스위트는 `.last-token`이 제공된 키와 일치함을 검증합니다.
138
+
131
139
  ### v6.2.3 (최신)
132
140
 
133
141
  - **Brainstorm Visual Companion 강화 (`server.cjs`)**:로컬 loopback 전용 HTTP+WebSocket 서버가 파일시스템 레이스에 안전하게 대응합니다(content 디렉터리 삭제 또는 화면 파일 소실 시 대기 페이지 / 404로 폴백)。watcher는 디렉터리 삭제·재생성 후 자가 치유됩니다(Linux inotify + macOS FSEvents)。WebSocket 핸드셰이크는 RFC 6455로 검증하고, 제어 프레임은 125바이트, idle/partial-frame deadline을 적용하며, 상한 도달 시 가장 오래된 연결을 제거합니다. nonce CSP, 시작별 키 로테이션, 화면·스킬·이벤트 크기 제한, private state 파일을 적용했습니다.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-6.2.3-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
5
+ [![Version](https://img.shields.io/badge/version-6.2.4-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
 
8
8
  This document summarizes the information and usage instructions for packaging the original Superpowers skills library into an independent MCP Toolpack.
@@ -128,7 +128,15 @@ These skills are designed for orchestrating complex meta-execution patterns with
128
128
 
129
129
  ## 🆕 Recent Updates
130
130
 
131
- ### v6.2.3 (Latest)
131
+ ### v6.2.4 (Latest)
132
+
133
+ - **Upstream alignment — persistent brainstorm sessions**: with `--project-dir`, the companion now persists its session key to `.superpowers/brainstorm/.last-token` (owner-only, gitignored) alongside `.last-port` and reuses it across restarts — an already-open browser tab stays connected after a restart, no URL re-sharing needed. Ephemeral `/tmp` sessions keep rotating the key per invocation, and an explicit `BRAINSTORM_TOKEN` env var still wins and is never persisted. Delete `.last-token` (server stopped) to force a fresh key.
134
+ - **Token-file read path hardened** (`readPrivateFile`): symlinked or multi-link `.last-token` files are rejected instead of being adopted as the session key, with the read performed through an `O_NOFOLLOW` fd whose identity is re-checked and tightened to 0600 — closing the asymmetry with the already-hardened write path (found by independent security review).
135
+ - **Diagnosability**: a failed token-file write now logs `Failed to write private token file:` instead of silently degrading to per-start key rotation.
136
+ - **start-server.ps1 env hygiene**: an ephemeral (no `--project-dir`) launch no longer inherits a stale project key/port from the invoking pwsh session.
137
+ - **Tests**: companion suite now 31 assertions — token persistence across restarts, pre-seeded file honored, symlinked token file rejected, rotation preserved without a token file; test cleanup is failure-safe (try/finally). PowerShell suite asserts `.last-token` matches the served key.
138
+
139
+ ### v6.2.3
132
140
 
133
141
  - **Hardened Brainstorming Visual Companion (`server.cjs`)**: the local loopback-only HTTP+WebSocket server is crash-resistant against filesystem races (a deleted content dir or a screen vanishing mid-read degrades to the waiting page / 404 instead of killing the process), and its watcher self-heals after the content dir is deleted and recreated (Linux inotify + macOS FSEvents). WebSocket handshakes are validated against RFC 6455 (version/upgrade/connection/key), control-frame payloads are capped at 125 bytes, clients have idle/partial-frame deadlines, and the oldest connection is evicted when the cap is full. Security headers now include `nosniff` and a nonce CSP; generated keys rotate per invocation; screen, skill, event, and user-event reads/logs are size-capped and state files are private.
134
142
  - **Companion security defaults**: the server only binds to loopback HTTP, rotates its key on every invocation, stores browser authentication only in an HttpOnly/SameSite cookie after the initial URL, and blocks unnonce'd scripts in screen HTML. Remote browsers must use an authenticated SSH tunnel; a restart requires sharing the new `server-info` URL.
package/README.zh-TW.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
4
4
 
5
- [![版本](https://img.shields.io/badge/version-6.2.3-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
5
+ [![版本](https://img.shields.io/badge/version-6.2.4-blue.svg)](https://github.com/Poseidoncode/superpowers-mcp)
6
6
  [![授權](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
7
 
8
8
  本文檔總結了將原始 Superpowers 技能庫打包成獨立 MCP Toolpack 的相關資訊與使用說明。
@@ -128,6 +128,14 @@
128
128
 
129
129
  ## 🆕 最近更新
130
130
 
131
+ ### v6.2.4 (最新版)
132
+
133
+ - **對齊上游 — brainstorm 持久化 session**:搭配 `--project-dir` 時,companion 現在會把 session 金鑰持久化到 `.superpowers/brainstorm/.last-token`(僅擁有者可讀、已列入 .gitignore),與 `.last-port` 並存並在重啟後重用——已開啟的瀏覽器分頁在重啟後依然保持連線,無需重新分享 URL。暫存 `/tmp` session 仍維持每次啟動輪換金鑰;明確設定的 `BRAINSTORM_TOKEN` env 依然優先且永不寫入檔案。若要強制輪換,請在停止伺服器後刪除 `.last-token`。
134
+ - **Token 檔讀取路徑加固**(`readPrivateFile`):symlink 或多重連結的 `.last-token` 現在會被拒絕,不再被採納為 session 金鑰;讀取透過 `O_NOFOLLOW` fd 進行,身份複查並收緊為 0600——補上與已加固寫入路徑之間的不對稱(由獨立資安審查發現)。
135
+ - **可診斷性**:token 檔寫入失敗現在會記錄 `Failed to write private token file:`,不再靜默退化為每次啟動輪換金鑰。
136
+ - **start-server.ps1 環境衛生**:無 `--project-dir` 的暫存啟動不再繼承呼叫端 pwsh session 中殘留的專案金鑰/埠號。
137
+ - **測試**:companion 套件現為 31 個斷言——重啟後金鑰持久化、預置檔原樣採用、symlink token 檔被拒絕、無 token 檔時仍輪換;測試清理具失敗安全性(try/finally)。PowerShell 套件斷言 `.last-token` 與伺服器提供之金鑰一致。
138
+
131
139
  ### v6.2.3 (最新版)
132
140
 
133
141
  - **Brainstorm Visual Companion 強化 (`server.cjs`)**:本機 loopback 限定的 HTTP+WebSocket 伺服器可安全處理檔案系統競態(content 目錄被刪除或畫面檔消失時降級為等待頁 / 404);watcher 在目錄刪除重建後自動自癒(Linux inotify + macOS FSEvents)。WebSocket handshake 依 RFC 6455 驗證,控制訊框上限 125 bytes,並加入 idle/partial-frame deadline,滿額時淘汰最舊連線。套用 nonce CSP、每次啟動輪換金鑰、畫面/技能/事件大小上限與私有 state 檔案。
package/out/server.js CHANGED
@@ -49,7 +49,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
49
49
  `}var vs=class{constructor(t=hf.default.stdin,r=hf.default.stdout){this._stdin=t,this._stdout=r,this._readBuffer=new gs,this._started=!1,this._ondata=n=>{this._readBuffer.append(n),this.processReadBuffer()},this._onerror=n=>{this.onerror?.(n)}}async start(){if(this._started)throw new Error("StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.");this._started=!0,this._stdin.on("data",this._ondata),this._stdin.on("error",this._onerror)}processReadBuffer(){for(;;)try{let t=this._readBuffer.readMessage();if(t===null)break;this.onmessage?.(t)}catch(t){this.onerror?.(t)}}async close(){this._stdin.off("data",this._ondata),this._stdin.off("error",this._onerror),this._stdin.listenerCount("data")===0&&this._stdin.pause(),this._readBuffer.clear(),this.onclose?.()}send(t){return new Promise(r=>{let n=j_(t);this._stdout.write(n)?r():this._stdout.once("drain",r)})}};var ze=sr(require("fs/promises")),ye=sr(require("path")),$n=10*1024*1024,ys=class{skillsPath;cachedSkills=null;loadingPromise=null;skillMap=new Map;contentCache=new Map;constructor(t){this.skillsPath=t}stripQuotes(t){return t.replace(/^"(.*)"$|^'(.*)'$/,"$1$2").trim()}parseFrontmatter(t){let r=t;if(r.charCodeAt(0)===65279&&(r=r.slice(1)),!r.startsWith("---"))return{name:"",description:""};let n=r.split(/\r?\n/),o=[],i=!1;for(let u=1;u<n.length;u++){if(n[u].trim()==="---"){i=!0;break}o.push(n[u])}if(!i)return{name:"",description:""};let a="",s="",c=!1;for(let u of o){let l=u.match(/^name:\s*(.*?)\s*$/);if(l){a=this.stripQuotes(l[1]),c=!1;continue}let d=u.match(/^description:\s*(.*?)\s*$/);if(d){s=this.stripQuotes(d[1]),c=!0;continue}c&&/^\s+/.test(u)?s+=" "+u.trim():c=!1}return{name:a,description:s}}async exists(t){try{return await ze.access(t),!0}catch{return!1}}async listSkills(t=!1){if(this.cachedSkills&&!t)return this.cachedSkills;if(this.loadingPromise&&!t)return this.loadingPromise;t&&this.contentCache.clear();let r=this.internalListSkills();this.loadingPromise=r;try{return await r}finally{this.loadingPromise===r&&(this.loadingPromise=null)}}async internalListSkills(){if(!await this.exists(this.skillsPath))return this.cachedSkills??[];let t=[],r=new Map,n=!1;try{let o=await ze.readdir(this.skillsPath,{withFileTypes:!0});for(let i of o){if(!i.isDirectory()&&!i.isSymbolicLink())continue;let a=ye.join(this.skillsPath,i.name),s=ye.join(a,"SKILL.md");if(await this.exists(s))try{let c=await this.readFileNoFollow(s,this.skillsPath),{name:u,description:l}=this.parseFrontmatter(c),m={name:u||i.name,description:l,skillPath:s};t.push(m),r.set(m.name.toLowerCase(),m);let p=ye.basename(ye.dirname(m.skillPath)).toLowerCase();r.set(p,m)}catch{process.stderr.write(`Warning: Failed to read skill file in directory "${i.name}"
50
50
  `)}}n=!0}catch(o){process.stderr.write(`Error reading skills directory: ${String(o)}
51
51
  `)}return n&&(this.skillMap=r,this.cachedSkills=t.sort((o,i)=>o.name.localeCompare(i.name))),this.cachedSkills??[]}async findSkill(t){let r=typeof t=="string"?t.trim():"";if(!(!r||r==="."||r===".."||r.includes("/")||r.includes("\\")||r.includes("\0")))return this.cachedSkills||await this.listSkills(),this.skillMap.get(r.toLowerCase())}async readFileNoFollow(t,r){let n=ye.resolve(t),o=ye.resolve(r),i=async()=>{let l=await ze.realpath(n),d=await ze.realpath(o);if(!(await ze.stat(d)).isDirectory())throw new Error("Skills directory must be a directory");let p=ye.relative(d,l);if(p===".."||p.startsWith(`..${ye.sep}`)||ye.isAbsolute(p))throw new Error("File is outside skills directory");let h=await ze.stat(l);if(!h.isFile()||h.nlink!==1)throw new Error("Skill path is not a regular file");if(h.size>$n)throw new Error("Skill file exceeds size limit");return{realFilePath:l,stat:h}},a=await i(),s=await i();if(a.realFilePath!==s.realFilePath||a.stat.dev!==s.stat.dev||a.stat.ino!==s.stat.ino)throw new Error("File changed while validating its path");let c=process.platform==="win32"?0:ze.constants.O_NOFOLLOW,u=await ze.open(s.realFilePath,ze.constants.O_RDONLY|c);try{let l=await u.stat();if(!l.isFile()||l.nlink!==1||l.dev!==s.stat.dev||l.ino!==s.stat.ino||l.size>$n)throw new Error("File changed while opening");let d=[],m=64*1024,p=0;for(;p<=$n;){let v=Buffer.allocUnsafe(Math.min(m,$n+1-p)),{bytesRead:$}=await u.read(v,0,v.length,null);if($===0)break;if(p+=$,d.push(v.subarray(0,$)),p>$n)throw new Error("Skill file exceeds size limit")}let h=await u.stat();if(h.size>$n||h.dev!==s.stat.dev||h.ino!==s.stat.ino)throw new Error("File changed while reading");return Buffer.concat(d,p).toString("utf-8")}finally{await u.close()}}async readSkillContent(t,r=!1){let n=ye.resolve(t),o=ye.resolve(this.skillsPath),i=n,a=o;try{i=await ze.realpath(n),a=await ze.realpath(o)}catch{}let s=ye.relative(a,i);if(s.startsWith("..")||ye.isAbsolute(s))throw new Error(`Access denied: path "${t}" is outside skills directory`);if(this.contentCache.has(i)&&!r)return this.contentCache.get(i);try{let c=await this.readFileNoFollow(t,this.skillsPath);c.charCodeAt(0)===65279&&(c=c.slice(1));let u=c.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/,"").trim();return this.contentCache.set(i,u),u}catch(c){throw new Error(`Failed to read skill content: ${c instanceof Error?c.message:String(c)}`)}}clearCache(){this.cachedSkills=null,this.loadingPromise=null,this.skillMap.clear(),this.contentCache.clear()}};function jT(){let e=process.env.SKILLS_PATH;if(e){let r=ct.resolve(e),n=ct.normalize(r).toLowerCase(),o=ct.parse(r).root.toLowerCase();if(n===o||["/etc","/var","/bin","/sbin","/usr","/root","/sys","/proc","/dev","c:\\windows","c:\\program files","c:\\program files (x86)"].some(s=>n===s||n.startsWith(s+ct.sep)))process.stderr.write(`Warning: Potentially unsafe SKILLS_PATH: "${e}". Fallback to default.
52
- `);else return r}let t=ct.join(__dirname,"..","skills");return E_.existsSync(t)?t:ct.join(__dirname,"skills")}var O_=jT(),It=new ys(O_),Pr=new hs({name:"superpowers-mcp",version:"6.2.3"},{capabilities:{resources:{subscribe:!1},prompts:{},tools:{}}});Pr.setRequestHandler(od,async()=>({resources:(await It.listSkills()).map(t=>({uri:`skill://superpowers/${encodeURIComponent(t.name)}`,name:t.name,description:t.description,mimeType:"text/markdown"}))}));Pr.setRequestHandler(ad,async e=>{let t=e.params.uri,r=t.match(/^skill:\/\/superpowers\/(.+)$/);if(!r)throw new D(M.InvalidRequest,`Invalid skill URI: ${t}`);let n;try{n=decodeURIComponent(r[1])}catch{throw new D(M.InvalidRequest,`Invalid skill URI: ${t}`)}let o=await It.findSkill(n);if(!o)throw new D(M.InvalidRequest,`Skill not found: ${n}`);try{let i=await It.readSkillContent(o.skillPath);return{contents:[{uri:t,mimeType:"text/markdown",text:i}]}}catch{throw new D(M.InternalError,"Failed to read skill content safely.")}});Pr.setRequestHandler(sd,async()=>({prompts:[{name:"session-start",description:"Inject the Superpowers context into an AI agent session. Tells the agent it has superpowers and how to use the skill system."}]}));Pr.setRequestHandler(cd,async e=>{if(e.params.name!=="session-start")throw new D(M.InvalidRequest,`Unknown prompt: ${e.params.name}`);let t=await It.findSkill("using-superpowers"),r="";if(t)try{r=await It.readSkillContent(t.skillPath)}catch{r=`# Superpowers
52
+ `);else return r}let t=ct.join(__dirname,"..","skills");return E_.existsSync(t)?t:ct.join(__dirname,"skills")}var O_=jT(),It=new ys(O_),Pr=new hs({name:"superpowers-mcp",version:"6.2.4"},{capabilities:{resources:{subscribe:!1},prompts:{},tools:{}}});Pr.setRequestHandler(od,async()=>({resources:(await It.listSkills()).map(t=>({uri:`skill://superpowers/${encodeURIComponent(t.name)}`,name:t.name,description:t.description,mimeType:"text/markdown"}))}));Pr.setRequestHandler(ad,async e=>{let t=e.params.uri,r=t.match(/^skill:\/\/superpowers\/(.+)$/);if(!r)throw new D(M.InvalidRequest,`Invalid skill URI: ${t}`);let n;try{n=decodeURIComponent(r[1])}catch{throw new D(M.InvalidRequest,`Invalid skill URI: ${t}`)}let o=await It.findSkill(n);if(!o)throw new D(M.InvalidRequest,`Skill not found: ${n}`);try{let i=await It.readSkillContent(o.skillPath);return{contents:[{uri:t,mimeType:"text/markdown",text:i}]}}catch{throw new D(M.InternalError,"Failed to read skill content safely.")}});Pr.setRequestHandler(sd,async()=>({prompts:[{name:"session-start",description:"Inject the Superpowers context into an AI agent session. Tells the agent it has superpowers and how to use the skill system."}]}));Pr.setRequestHandler(cd,async e=>{if(e.params.name!=="session-start")throw new D(M.InvalidRequest,`Unknown prompt: ${e.params.name}`);let t=await It.findSkill("using-superpowers"),r="";if(t)try{r=await It.readSkillContent(t.skillPath)}catch{r=`# Superpowers
53
53
 
54
54
  You have superpowers. Use the read_skill and list_skills tools to discover and load skills.`}else{let o=ct.join(O_,"using-superpowers","SKILL.md");try{r=await It.readSkillContent(o)}catch{r=`# Superpowers
55
55
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "superpowers-mcp",
3
3
  "displayName": "Superpowers MCP",
4
4
  "description": "Superpowers skills library (TDD, debugging, collaboration workflows) as an MCP server for VSCode and Antigravity",
5
- "version": "6.2.3",
5
+ "version": "6.2.4",
6
6
  "publisher": "superpowers",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -101,9 +101,17 @@ function decodeFrame(buffer) {
101
101
  // ========== Configuration ==========
102
102
 
103
103
  const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
104
+ // Per-session secret key. The companion is reachable by any local browser tab.
105
+ // The key authenticates the client, rides the served URL as ?key=, and is
106
+ // mirrored into a HttpOnly cookie on first load so the WebSocket and /files/*
107
+ // subresources authenticate for free. When BRAINSTORM_TOKEN_FILE is set (the
108
+ // persistent --project-dir launcher mode), the key is persisted alongside the
109
+ // port so a restart reuses it and an already-open browser tab's cookie keeps
110
+ // validating. Without a token file the key is rotated on every server start.
111
+ const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
104
112
  const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
105
113
  // Prefer an explicit port, else the port this session last bound, else a random
106
- // high port. Authentication is intentionally rotated on every server start.
114
+ // high port.
107
115
  function preferredPort() {
108
116
  if (process.env.BRAINSTORM_PORT) {
109
117
  const p = Number(process.env.BRAINSTORM_PORT);
@@ -137,7 +145,10 @@ const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
137
145
  const CONTENT_DIR = path.join(SESSION_DIR, 'content');
138
146
  const STATE_DIR = path.join(SESSION_DIR, 'state');
139
147
  const SUPERPOWERS_VERSION = readSuperpowersVersion();
140
- const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
148
+ // The brand mark is served locally from /brand-logo.svg so the page never
149
+ // contacts a third-party host; the SVG payload is generated inline below.
150
+ const SUPERPOWERS_BRAND_IMAGE_URL = '/brand-logo.svg';
151
+ const BRAND_LOGO_SVG = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 24' role='img' aria-label='Superpowers'><g fill='#fff'><path d='M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z' transform='translate(0 2.5) scale(0.62)'/><text x='24' y='17.5' font-family='system-ui, sans-serif' font-size='15' font-weight='700' letter-spacing='2.5'>SUPERPOWERS</text></g></svg>";
141
152
  const TELEMETRY_DISABLE_ENV_VARS = [
142
153
  'SUPERPOWERS_DISABLE_TELEMETRY',
143
154
  'DISABLE_TELEMETRY',
@@ -146,21 +157,68 @@ const TELEMETRY_DISABLE_ENV_VARS = [
146
157
  const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
147
158
  let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
148
159
 
149
- // Each server invocation gets a fresh 256-bit secret. An explicitly supplied
150
- // BRAINSTORM_TOKEN is retained for controlled integrations, but launchers never
151
- // persist it across logical sessions.
160
+ // A 256-bit secret. An explicitly supplied BRAINSTORM_TOKEN is retained for
161
+ // controlled integrations. In persistent launcher mode (BRAINSTORM_TOKEN_FILE)
162
+ // the key is reused across restarts so already-open tabs stay authenticated;
163
+ // otherwise each server invocation gets a fresh key. Persistence is best effort:
164
+ // a write failure must never prevent the server from starting.
152
165
  function generateToken() {
153
166
  return crypto.randomBytes(32).toString('hex');
154
167
  }
155
168
 
169
+ // Mirror of writePrivateFile for the read path: the token file must be a
170
+ // regular, single-link, non-symlink file. The fd is opened with O_NOFOLLOW and
171
+ // its identity re-checked, and it is tightened to 0600 via the fd — never a
172
+ // path-based chmod, which would follow a symlink to an attacker-chosen target.
173
+ // Returns the file content or null on any violation/error.
174
+ function readPrivateFile(filePath) {
175
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
176
+ try {
177
+ const before = fs.lstatSync(filePath);
178
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1) return null;
179
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
180
+ try {
181
+ const stat = fs.fstatSync(fd);
182
+ if (!stat.isFile() || stat.nlink !== 1 || !sameFileIdentity(before, stat)) return null;
183
+ fs.fchmodSync(fd, 0o600);
184
+ return fs.readFileSync(fd, 'utf-8');
185
+ } finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ } catch (e) {
189
+ return null;
190
+ }
191
+ }
192
+
156
193
  function initialToken() {
157
194
  if (process.env.BRAINSTORM_TOKEN) {
158
195
  const t = String(process.env.BRAINSTORM_TOKEN).trim();
159
196
  if (/^[0-9a-f]{32,}$/i.test(t)) {
197
+ // A file path is not touched when the env token wins: the env value is a
198
+ // transient override for controlled integrations, and persisting it would
199
+ // turn a one-off secret into a durable one. Note the interaction: a later
200
+ // launch without the env var falls back to whatever .last-token holds.
160
201
  return { value: t, source: 'env' };
161
202
  }
162
203
  }
163
- return { value: generateToken(), source: 'generated' };
204
+ if (TOKEN_FILE && path.isAbsolute(TOKEN_FILE)) {
205
+ // No prior token recorded (or the file fails the regular-file checks below)
206
+ // — either way, fall through to generating a fresh key.
207
+ const t = readPrivateFile(TOKEN_FILE);
208
+ if (t !== null && /^[0-9a-f]{32,}$/i.test(t.trim())) {
209
+ return { value: t.trim(), source: 'file' };
210
+ }
211
+ }
212
+ const generated = { value: generateToken(), source: 'generated' };
213
+ if (TOKEN_FILE && path.isAbsolute(TOKEN_FILE)) {
214
+ // Persist the key so a restart reuses it; best effort only — a write
215
+ // failure must never prevent the server from starting, but it silently
216
+ // degrades "restart keeps the tab alive" into per-start rotation.
217
+ if (!writePrivateFile(TOKEN_FILE, generated.value + '\n')) {
218
+ console.error('Failed to write private token file:', TOKEN_FILE);
219
+ }
220
+ }
221
+ return generated;
164
222
  }
165
223
 
166
224
  const tokenInfo = initialToken();
@@ -252,7 +310,7 @@ function brandMarkup() {
252
310
  : 'Superpowers v' + version;
253
311
  const logo = SUPERPOWERS_TELEMETRY_DISABLED
254
312
  ? ''
255
- : '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '?v=' + encodeURIComponent(SUPERPOWERS_VERSION) + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
313
+ : '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
256
314
 
257
315
  return '<div class="brand"><a href="https://github.com/obra/superpowers">' + logo + '<span class="brand-copy">' + text + '</span></a></div>';
258
316
  }
@@ -571,7 +629,7 @@ function securityHeaders(headers = {}, scriptNonce = null) {
571
629
  'Cache-Control': 'no-store',
572
630
  'X-Frame-Options': 'DENY',
573
631
  'X-Content-Type-Options': 'nosniff',
574
- 'Content-Security-Policy': "default-src 'none'; script-src " + scriptSource + "; style-src 'unsafe-inline'; img-src 'self' https://primeradiant.com data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'",
632
+ 'Content-Security-Policy': "default-src 'none'; script-src " + scriptSource + "; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'",
575
633
  'Cross-Origin-Resource-Policy': 'same-origin',
576
634
  ...headers
577
635
  };
@@ -619,6 +677,10 @@ function handleRequest(req, res) {
619
677
 
620
678
  res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }, nonce));
621
679
  res.end(html);
680
+ } else if (req.method === 'GET' && pathname === '/brand-logo.svg') {
681
+ // Locally-generated brand mark; same-origin so img-src 'self' suffices.
682
+ res.writeHead(200, securityHeaders({ 'Content-Type': 'image/svg+xml; charset=utf-8' }));
683
+ res.end(BRAND_LOGO_SVG);
622
684
  } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
623
685
  const fileName = path.basename(pathname.slice(7));
624
686
  const filePath = path.join(CONTENT_DIR, fileName);
@@ -1067,8 +1129,11 @@ function startServer() {
1067
1129
  // one after an EADDRINUSE fallback) so it can't collide with another server's
1068
1130
  // cookie in the shared localhost jar.
1069
1131
  COOKIE_NAME = 'brainstorm-key-' + PORT;
1070
- // Record the bound port only when we got our preferred port. Authentication
1071
- // is deliberately not persisted, so a new logical session gets a new key.
1132
+ // Record the bound port only when we got our preferred port, so a stale
1133
+ // port file from a crashed fallback can't pin a future session to a port
1134
+ // another server owns. The session key, by contrast, is persisted whenever
1135
+ // BRAINSTORM_TOKEN_FILE is set (see initialToken) — the pair is what keeps
1136
+ // an already-open tab's cookie valid across a restart.
1072
1137
  if (PORT_FILE && !triedFallback) {
1073
1138
  if (!writePrivateFile(PORT_FILE, String(PORT))) {
1074
1139
  console.error('Failed to write private port file');
@@ -156,9 +156,17 @@ $brainstormRoot = ""
156
156
  if ($projectDir -ne "") {
157
157
  $brainstormRoot = Join-Path $projectDir ".superpowers/brainstorm"
158
158
  $sessionDir = Join-Path $brainstormRoot $sessionId
159
+ # Reuse the last bound port and session key so a restart keeps an
160
+ # already-open browser tab connected to the same URL with a valid cookie.
159
161
  $env:BRAINSTORM_PORT_FILE = Join-Path $brainstormRoot ".last-port"
162
+ $env:BRAINSTORM_TOKEN_FILE = Join-Path $brainstormRoot ".last-token"
160
163
  } else {
161
164
  $sessionDir = Join-Path ([System.IO.Path]::GetTempPath()) "brainstorm-$sessionId"
165
+ # $env: assignments persist in the invoking pwsh session; a stale project
166
+ # token/port file from an earlier --project-dir run must not leak into an
167
+ # ephemeral session (it would defeat key rotation and could overwrite the
168
+ # project's .last-token).
169
+ Remove-Item Env:BRAINSTORM_TOKEN_FILE, Env:BRAINSTORM_PORT_FILE -ErrorAction SilentlyContinue
162
170
  }
163
171
 
164
172
  $stateDir = Join-Path $sessionDir "state"
@@ -247,6 +255,7 @@ $envValues = @{
247
255
  BRAINSTORM_URL_HOST = $urlHost
248
256
  BRAINSTORM_OWNER_PID = ""
249
257
  BRAINSTORM_PORT_FILE = $env:BRAINSTORM_PORT_FILE
258
+ BRAINSTORM_TOKEN_FILE = $env:BRAINSTORM_TOKEN_FILE
250
259
  BRAINSTORM_IDLE_TIMEOUT_MS = $env:BRAINSTORM_IDLE_TIMEOUT_MS
251
260
  BRAINSTORM_OPEN = $env:BRAINSTORM_OPEN
252
261
  }
@@ -135,9 +135,10 @@ SESSION_ID="$$-$(date +%s)"
135
135
 
136
136
  if [[ -n "$PROJECT_DIR" ]]; then
137
137
  SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
138
- # Reusing a port is safe because the server rotates its authentication key
139
- # for every logical session.
138
+ # Reuse the last bound port and session key so a restart keeps an
139
+ # already-open browser tab connected to the same URL with a valid cookie.
140
140
  export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port"
141
+ export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token"
141
142
  else
142
143
  SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
143
144
  fi
@@ -53,8 +53,11 @@ without it, so always give the user the **complete** URL from the `url` field fo
53
53
  the first load — never strip the query string. The key gates HTTP access, then
54
54
  moves into an `HttpOnly`/`SameSite=Strict` cookie; the browser's same-origin
55
55
  WebSocket automatically sends that cookie. The key is never stored in
56
- page-readable storage. A new server invocation rotates the key, so a restarted
57
- server requires the new URL.
56
+ page-readable storage. With `--project-dir`, the key is persisted to
57
+ `.superpowers/brainstorm/.last-token` and reused across restarts, so an
58
+ already-open tab stays connected; delete that file (with the server stopped)
59
+ to force a fresh key. Without it, a new server invocation rotates the key and
60
+ a restarted server requires the new URL.
58
61
 
59
62
  **Finding connection info:** The server writes its startup JSON to `$STATE_DIR/server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. When using `--project-dir`, check `<project>/.superpowers/brainstorm/` for the session directory.
60
63
 
@@ -104,7 +107,7 @@ The `--url-host` value must also be a loopback hostname or address.
104
107
  ## The Loop
105
108
 
106
109
  1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`:
107
- - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it may reuse the same port, but the authentication key rotates, so share the new URL from `server-info` with the user. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`).
110
+ - **Required: confirm the server is alive before referring to the URL or pushing a screen.** Check that `$STATE_DIR/server-info` exists and `$STATE_DIR/server-stopped` does not. If it has shut down, restart it with `start-server.sh` using the **same `--project-dir`** — it reuses the same port and session key (from `.last-token`), so an already-open tab keeps working; without `--project-dir` the key rotates, so share the new URL from `server-info` with the user. The server auto-exits after 4 hours idle (configurable with `--idle-timeout-minutes`).
108
111
  - Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
109
112
  - **Never reuse filenames** — each screen gets a fresh file
110
113
  - Use your file-creation tool — **never use cat/heredoc** (dumps noise into terminal)