@buoy-gg/mcp 7.0.48 → 7.0.49
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/dist/{chunk-OBR5RY7Z.mjs → chunk-MLEZPZP3.mjs} +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import{formatWebBenchMarkdown as zr,runWebBench as Jr}from"./chunk-2PTJT2QF.mjs";import{DEFAULT_BROKER_PORT as Wr}from"@buoy-gg/sync-broker";var ut=Wr;var Kt=`http://localhost:${ut}`;var Ht=15e3;var Gr=2500;var Vr=1200;var Yt=2e4;var Kr=3e4;var Qt=5e3;var Hr=15*6e4;var dt="Dashboard";var Y="All";import{readFileSync as Xt,writeFileSync as Yr,mkdirSync as Qr,renameSync as Xr,unlinkSync as Zr}from"fs";import{dirname as es,join as Zt,resolve as ts}from"path";import{homedir as ns}from"os";import{createHash as rs}from"crypto";import{validateLicense as ss,isAcceptedValidation as os,isTransientLicenseError as as,resolveTier as is,isRecognizedAccount as cs,isBuoyToken as ls,verifyFreeToken as us,resolveAccountAccess as ds,signCache as Ne,verifyCacheSignature as hs,LICENSE_LAUNCH_VALIDATION_TTL as en,LICENSE_REVALIDATION_INTERVAL as pe,LICENSE_TRANSIENT_RETRY_DELAYS as tn,KEYGEN_CONFIG as ps,createNodeSession as ms,CLI_AUDIENCE as fs,exchangeServiceKey as gs,isServiceKey as vs,normalizeLicenseKey as nn,checkDevToken as ws,isDevToken as bs}from"@buoy-gg/license/headless";var rn=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function ht(e,n=process.env){for(const t of rn)if(n[t]!==void 0)return nn(n[t])||null;for(const t of[".env.development.local",".env.local",".env"]){let r;try{r=Xt(Zt(e,t),"utf8")}catch{continue}const s=new Map;for(const o of r.split(/\r?\n/)){const i=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!i)continue;const a=i[2].trim();const c=a.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(i[1],c?c[2]:a.split("#")[0].trim())}for(const o of rn)if(s.has(o))return nn(s.get(o))||null}return null}var ys=class{constructor(e,n={}){this.changed=e;this.key=null;this.cache=null;this.timer=null;this.unsubscribeSession=null;this.generation=0;this.busy=false;this.nextCheck=0;this.retry=0;this.lastAdmission=false;this.hasAccess=()=>this.hasKeyAccess()||this.session?.getStatus().state==="active";this.lastSessionSync=0;this.projectDir=ts(n.projectDir??process.env.BUOY_PROJECT_DIR??process.cwd());const t=rs("sha256").update(this.projectDir).digest("hex").slice(0,20);this.cacheFile=n.cacheFile??Zt(ns(),".buoy",`account-${t}.json`);this.session=n.session!==void 0?n.session:ms({audience:fs,openBrowser:()=>{throw new Error("The MCP does not open a browser. Run `npx buoy login`.")}})}hasKeyAccess(){return ds({initialized:true,validating:false,key:this.key,tierSource:this.cache?.tier?"key":"none",verifiedAt:this.cache?.cachedAt??null}).status==="admitted"}start(){if(this.timer)return;if(this.session){this.unsubscribeSession=this.session.subscribe(()=>this.notify());void this.session.load().then(()=>this.notify(),()=>{})}try{const e=JSON.parse(Xt(this.cacheFile,"utf8"));if(e.valid&&hs(e))this.cache=e}catch{}void this.tick();this.timer=setInterval(()=>{void this.tick()},1e3);this.timer.unref()}stop(){++this.generation;this.unsubscribeSession?.();this.unsubscribeSession=null;if(this.timer)clearInterval(this.timer);this.timer=null;this.key=null;this.cache=null;this.notify()}notify(){const e=this.hasAccess();if(e===this.lastAdmission)return;this.lastAdmission=e;this.changed()}async tick(){if(this.session&&Date.now()-this.lastSessionSync>15e3){this.lastSessionSync=Date.now();void this.session.sync().catch(()=>{})}const e=ht(this.projectDir);if(e!==this.key){++this.generation;this.key=e;if(this.cache?.licenseKey!==e)this.cache=null;this.nextCheck=0;this.retry=0;if(!e)this.removeCache()}this.notify();if(!e||this.busy||Date.now()<this.nextCheck)return;if(this.cache&&this.hasKeyAccess()&&Date.now()-this.cache.lastValidatedAt<en&&this.nextCheck===0){this.nextCheck=this.cache.lastValidatedAt+en;return}this.busy=true;const n=this.generation;try{if(bs(e)){const a=ws(e);if(!a.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),a.claims.iat*1e3);const u=a.claims.tier==="free"?"free":"pro";this.cache=Ne({licenseKey:e,licenseId:`dev:${a.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(a.claims.exp*1e3).toISOString(),cachedAt:c,lastValidatedAt:Date.now()});this.persistCache()}this.nextCheck=Date.now()+pe;return}if(vs(e)){const a=await gs(e,{origin:process.env.BUOY_SITE_URL||"https://buoy.gg"});if(n!==this.generation||ht(this.projectDir)!==e)return;if(!a.ok){if(a.reason==="rejected"){this.cache=null;this.removeCache();this.nextCheck=Date.now()+pe}else{this.nextCheck=Date.now()+(tn[this.retry++]??pe)}return}const c=Date.now();const u=a.claims.exp*1e3;this.cache=Ne({licenseKey:e,licenseId:`service:${a.claims.sid}`,valid:true,tier:"pro",isPro:true,expiresAt:new Date(u).toISOString(),cachedAt:c,lastValidatedAt:c});this.persistCache();this.retry=0;this.nextCheck=Math.max(c+6e4,u-60*60*1e3);return}if(ls(e)){const a=us(e);if(!a){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=Ne({licenseKey:e,licenseId:`token:${a.accountHash}`,valid:true,tier:"free",isPro:false,expiresAt:null,cachedAt:c,lastValidatedAt:c});this.persistCache()}this.nextCheck=Date.now()+pe;return}const t=await ss(e);if(n!==this.generation||ht(this.projectDir)!==e)return;const r=t.data?.relationships?.product?.data?.id;const s=t.meta.code==="EXPIRED"&&(cs(t.data)||this.cache?.licenseKey===e);if(r&&r!==ps.productId||!s&&!os(t)){this.cache=null;this.removeCache();this.nextCheck=Date.now()+pe;return}const o=s?"free":is(t.data);const i=Date.now();this.cache=Ne({licenseKey:e,licenseId:t.data?.id??"",valid:true,tier:o,isPro:o==="pro",expiresAt:s?null:t.data.attributes.expiry,cachedAt:i,lastValidatedAt:i});this.persistCache();this.retry=0;this.nextCheck=i+pe}catch(t){if(n!==this.generation)return;const r=as(t)?tn[this.retry++]:void 0;this.nextCheck=Date.now()+(r??pe)}finally{this.busy=false;this.notify()}}persistCache(){try{Qr(es(this.cacheFile),{recursive:true,mode:448});Yr(`${this.cacheFile}.tmp`,JSON.stringify(this.cache),{mode:384});Xr(`${this.cacheFile}.tmp`,this.cacheFile)}catch{}}removeCache(){try{Zr(this.cacheFile)}catch{}}};import $s from"http";import{randomUUID as ks}from"crypto";import{io as _s}from"socket.io-client";import{createBroker as Ss,brokerTokenPath as pt,readBrokerToken as xs,writeBrokerToken as As,DASHBOARD_TOKEN_REQUIRED as Ts}from"@buoy-gg/sync-broker";function Ds(e,n){return e==="app"&&(n==="ping"||n==="reloadApp")}function sn(e){return e>=2}function Is(e){if(Ds(e.toolId,e.action)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(!sn(e.consecutiveTimeouts)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(e.now-e.lastFullBudgetAt>e.recoveryProbeMs){return{timeoutMs:e.fullMs,usedFullBudget:true}}return{timeoutMs:e.shortMs,usedFullBudget:false}}function Ms(e){if(e.consecutiveTimeouts>=2)return true;if(e.lastOkAt>0&&e.now-e.lastOkAt>e.unresponsiveAfterMs){return true}return false}var Rs=class{constructor(e={}){this.socket=null;this.admitted=false;this.lockedDevices=new Set;this.hostAccount=null;this.broker=null;this.capabilities=new Map;this.devices=[];this.snapshots=new Map;this.watching=new Map;this.pendingActions=new Map;this.snapshotWaiters=new Map;this.snapshotAt=new Map;this.health=new Map;this.devServerOrigins=new Map;this.originProbed=new Set;this.lastSeen=new Map;this.deviceConnections=new Map;this.port=e.port??ut;this.url=e.url??(e.port?`http://localhost:${e.port}`:Kt);this.actionTimeoutMs=e.actionTimeoutMs??Ht;this.log=e.log??(n=>process.stderr.write(`[buoy-mcp] ${n}
|
|
2
|
-
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await on(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new ys(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=Ss({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await Os(this.url);try{As(this.broker.dashboardToken,pt(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=_s(this.url,{transports:["websocket"],query:{deviceName:dt},auth:a=>{const c=this.broker?.dashboardToken??xs(pt(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===Ts;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${pt(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=ie(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===Y){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-Hr;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==dt&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===Y){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return Y}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return sn(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=Is({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:Gr,recoveryProbeMs:Yt,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=Ms({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:Kr});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(ie(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(ie(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(ie(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=ie(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=ie(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(ie(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=ie(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??Qt;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>Yt){u.lastFullBudgetAt=Date.now()}else{o=Vr}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===Y?Y:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=ks();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==Y&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===Y?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==Y&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var ie=(e,n)=>`${e}::${n}`;function on(e,n=800){return new Promise(t=>{const r=$s.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function Os(e,n=20){for(let t=0;t<n;t++){if(await on(e,300))return;await Cs(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var Cs=e=>new Promise(n=>setTimeout(n,e));var me="7.0.48"?"7.0.48":"0.0.0-dev";var ce="@buoy-gg/mcp";import{promises as an}from"fs";import Fs from"os";import Es from"path";var Bs=24*60*60*1e3;var js=1500;var qs=`https://registry.npmjs.org/${ce}/latest`;var cn=Es.join(Fs.tmpdir(),"buoy-mcp-update-check.json");function Ps(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function Ns(){try{const e=await an.readFile(cn,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function Ls(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),js);try{const r=await fetch(qs,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await an.writeFile(cn,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function Us(e){const n=await Ns();if(n&&e-n.checkedAt<Bs)return n.latest;return Ls(e)}async function ln(e=Date.now()){if(me==="0.0.0-dev")return null;try{const n=await Us(e);if(n&&Ps(n,me)){return`A newer buoy-mcp is available (${n}; you're on ${me}). Update by re-running: npx -y ${ce}@latest init \u2014 then restart your editor.`}}catch{}return null}import{z as Q}from"zod";function Re(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function mt(e){return{isError:true,content:[{type:"text",text:e}]}}function un(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await ln();const r=n.listDevices();if(r.length===0){return Re({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return Re({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:Q.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return mt(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return Re({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:Q.string().describe("The tool id to read state from."),deviceId:Q.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:Q.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return Re({toolId:t,deviceId:o,snapshot:i})}catch(o){return mt(dn(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:Q.string().describe('The tool id, e.g. "perf-monitor".'),action:Q.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:Q.record(Q.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:Q.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:Q.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return Re({toolId:t,action:r,ok:true,result:c??null})}catch(a){return mt(dn(a))}})}function dn(e){return e instanceof Error?e.message:String(e)}var Se=(e,n,t)=>Math.max(n,Math.min(t,e));var zs="__batch_warmup__";var Js={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var xe={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function Ae(e,n,t){return e??n??t}function Le(e,n={}){const t=[];const r=(e.targetRoute||"").trim();if(!r)throw new Error("targetRoute is required (the screen to benchmark).");let s=(e.bounceRoute||"").trim();if(!s)s=r==="/"?"/_buoy_bounce":"/";if(s===r){throw new Error(`bounceRoute ("${s}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`)}if(!Array.isArray(e.cases)||e.cases.length===0){throw new Error("At least one case is required.")}const o=e.cases.map((d,h)=>{const $=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:$,params:d.params??{},route:d.route}});const i=Se(Math.round(Ae(e.runsPerCase,n.runsPerCase,xe.runsPerCase)),1,10);const a=Se(Math.round(Ae(e.coolDownMs,n.coolDownMs,xe.coolDownMs)),0,3e4);const c=Se(Math.round(Ae(e.discardWarmupRuns,n.discardWarmupRuns,xe.discardWarmupRuns)),0,i-1);const u=e.shuffleCases??n.shuffleCases??o.length>3;if(o.length>3&&u===false){t.push("shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it.")}if(i===1){t.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.")}const l={targetRoute:r,bounceRoute:s,perCaseDurationMs:Se(Math.round(Ae(e.perCaseDurationMs,n.perCaseDurationMs,xe.perCaseDurationMs)),1e3,6e4),settleMs:Se(Math.round(Ae(e.settleMs,n.settleMs,xe.settleMs)),0,1e4),navTimeoutMs:Se(Math.round(Ae(e.navTimeoutMs,n.navTimeoutMs,xe.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,discardWarmupCase:e.discardWarmupCase??n.discardWarmupCase??true,shuffleCases:u,reloadBetweenCases:e.reloadBetweenCases??n.reloadBetweenCases??false,captureRenders:e.captureRenders??n.captureRenders??true};return{config:l,warnings:t}}function hn(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=ft(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const i=[`**${n(e.perCaseDurationMs)} measured per page**`,t>1?`**${t} runs each** (median taken)`:`**1 run each** (no median \u2014 noisier)`,e.reloadBetweenCases?"**full reload between pages** (no state leaks across tests)":"no reload between pages",e.shuffleCases?"shuffled order (cancels thermal bias)":"fixed order",e.captureRenders!==false?"render capture on (per-component why-data)":"render capture off"];const a=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${i.join(", ")}. That's ~${n(a)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function ft(e){const n=1800;const t=e.settleMs+e.perCaseDurationMs+e.coolDownMs+n;let r=e.cases.length*(e.runsPerCase??1)*t;if(e.reloadBetweenCases)r+=e.cases.length*3e3;return r}function Ws(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.uiFps!=null&&t&&e.uiFps<t*.95){n.push("UI thread drops frames")}else if(e.uiJank!=null&&e.uiJank>=2){n.push("UI jank spikes")}if(e.cpu!=null&&e.cpu>=90)n.push("CPU near cap");if(e.jsJank!=null&&e.jsJank>=5)n.push("JS thread stalls");return n}function Gs(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Main thread drops frames")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}if(e.frameGapMaxMs!=null&&e.frameGapMaxMs>=100){n.push(`Freeze of ${Math.round(e.frameGapMaxMs)} ms`)}if(e.longTaskPct!=null&&e.longTaskPct>=20)n.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50){n.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`)}if(e.styleLayoutMs!=null&&e.styleLayoutMs>=100){n.push(`Style and layout in slow frames (${Math.round(e.styleLayoutMs)} ms)`)}if(e.worstInteractionMs!=null&&e.worstInteractionMs>=200){n.push(`Slow response to input (${Math.round(e.worstInteractionMs)} ms)`)}if(e.layoutShift!=null&&e.layoutShift>=.1)n.push(`Layout shifts (${e.layoutShift})`);if(e.domGrowth!=null&&e.domGrowth>0)n.push(`DOM grows each run (+${e.domGrowth} nodes)`);return n}function Vs(e){const n=e.filter(r=>!r.batchFailureReason).sort((r,s)=>(r.runIndex??0)-(s.runIndex??0)).map(r=>r.webRun?.domNodesStart).filter(r=>typeof r==="number");if(n.length<3)return null;for(let r=1;r<n.length;r++)if(n[r]<=n[r-1])return null;const t=n[n.length-1]-n[0];return t>=Math.max(50,n[0]*.05)?t:null}function Ks(e,n){const t=e.find(s=>s.isBaseline);if(t)return t;const r=n?.[0]?.name;if(r){const s=e.filter(o=>o.caseName===r);if(s.length===1)return s[0]}return e[0]}function Ue(e,n,t,r=[]){const s=e.filter(p=>p.batchId===n);const o=new Map(r.map(p=>[p.index,p.reason]));const i=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,g)=>p-g);const a=p=>{const g=s.filter(v=>(v.batchIndex??0)===p);if(g.length===0)return void 0;return g.find(v=>v.isMedianRun===true)??g.find(v=>v.runIndex===0||v.runIndex===void 0)??g[0]};const c=p=>typeof p==="number"?p:null;const u=i.map(p=>{const g=a(p);const v=s.filter(G=>(G.batchIndex??0)===p);const b=v.length;const A=v.filter(G=>!G.batchFailureReason);const T=G=>G.webRun?G.jsFpsAvg:G.uiFpsAvg;const R=A.map(T).filter(G=>typeof G==="number");const B=g?.name??t?.[p]?.name??`case ${p+1}`;const lt=s.some(G=>(G.batchIndex??0)===p&&G.isBaseline===true);const O=o.get(p)??g?.batchFailureReason;const C=Boolean(O)||!g;const F={batchIndex:p,caseName:B,params:g?.params??{},runs:b,failed:C,...lt?{isBaseline:true}:{},failureReason:C?O??"no run recorded":void 0,uiFps:c(g?.uiFpsAvg),jsFps:c(g?.jsFpsAvg),cpu:c(g?.cpuAvg),memMb:c(g?.memMaxMb),jsJank:c(g?.jsJankFrames),uiJank:c(g?.uiJankFrames),deviceMaxRefreshRate:c(g?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:A.length,fpsMin:R.length?te(Math.min(...R),1):null,fpsMax:R.length?te(Math.max(...R),1):null,...g?.thermalState?{thermalState:g.thermalState}:{},renderCommits:c(g?.renderCommits),renderWasted:g?.renderWasted,topRenderers:g?.topRenderers??[]};const E=g?.webRun;if(!E)return{...F,flags:C?[]:Ws(F)};const Gt=[...E.longTasksSupported?[]:["long tasks"],...E.heapSupported?[]:["JS heap"],...E.loaf===null?["long animation frames"]:[]];const Vt={...F,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:E.heapSupported?F.memMb:null,deviceMaxRefreshRate:Math.max(F.deviceMaxRefreshRate??0,E.refreshHz??0)||null,longTaskPct:E.longTaskPct,frameGapP95Ms:E.frameGapP95Ms,frameGapMaxMs:E.frameGapMaxMs,longFrames:E.longFrames,...Gt.length?{missingMetrics:Gt}:{},loafBlockingMs:E.loaf?.blockingMs??null,styleLayoutMs:E.loaf?.styleLayoutMs??null,forcedLayoutMs:E.loaf?.forcedLayoutMs??null,topScripts:E.loaf?.topScripts??[],worstInteractionMs:E.interactions?.worstMs??null,layoutShift:E.layoutShift??null,domNodes:E.domNodesEnd??null,domGrowth:Vs(v),pageLoad:E.pageLoad??null};return{...Vt,flags:C?[]:Gs({...Vt,frames:E.frames})}}).filter(p=>p.caseName!==zs).map((p,g)=>({...p,batchIndex:g}));const l=Ks(u,t);if(l){l.isBaseline=true;for(const p of u){if(p===l)continue;if(p.uiFps!=null&&l.uiFps!=null)p.deltaUiFps=te(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=te(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,g)=>{let v;let b=NaN;for(const A of d){const T=p(A);if(T==null)continue;if(v===void 0||g(T,b)){v=A;b=T}}return v?.caseName};const $=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const y={uiFps:h(p=>p.uiFps,(p,g)=>p>g),jsFps:h(p=>p.jsFps,(p,g)=>p>g),lowestCpu:h(p=>p.cpu,(p,g)=>p<g),lowestMem:h(p=>p.memMb,(p,g)=>p<g),fewestJank:h($,(p,g)=>p<g),fewestLongTasks:h(p=>p.longTaskPct??null,(p,g)=>p<g)};const f=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const w=d.filter(p=>p.flags.length===0).sort((p,g)=>(g.uiFps??g.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(g.cpu??g.longTaskPct??Infinity)||(p.memMb??Infinity)-(g.memMb??Infinity)).map(p=>p.caseName);return{batchId:n,baselineName:l?.caseName??null,deviceMaxRefreshRate:d.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:y,flagged:f,balanced:w}}var Hs=2e4;function Ys(e){const n=e.filter(r=>!r.failed);if(n.length===0||!n.every(r=>r.pageLoad&&r.pageLoad.pageAgeMs<Hs)){return[]}const t=r=>r==null?"\u2014":String(Math.round(r));return[`**Page load** (each case started on a fresh page load):`,"",`| Case | TTFB ms | FCP ms | LCP ms | DOM ready ms | Script KB | Total KB |`,`|------|--------:|-------:|-------:|-------------:|----------:|---------:|`,...n.map(r=>{const s=r.pageLoad;return`| ${r.caseName} | ${t(s.ttfbMs)} | ${t(s.fcpMs)} | ${t(s.lcpMs)} | ${t(s.domContentLoadedMs)} | ${t(s.scriptKb)} | ${t(s.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function pn(e){const n=o=>o.platform==="web"?o.jsFps:o.uiFps;const t=e.filter(o=>!o.failed&&n(o)!=null&&o.fpsMin!=null&&o.fpsMax!=null).sort((o,i)=>(n(i)??0)-(n(o)??0));if(t.length<2)return null;const[r,s]=t;if((r.validRuns??0)<2||(s.validRuns??0)<2){return`**Too few runs to separate the top cases.** ${r.caseName} and ${s.caseName} each need 2+ valid runs before their FPS gap means anything.`}if((s.fpsMax??0)>=(r.fpsMin??0)){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap across runs. Run more repeats before picking between them.`}return null}function ze(e){const n=(h,$=1)=>h==null?"\u2014":h.toFixed($);const t=h=>h==null?"":h>0?` (+${h.toFixed(1)})`:` (${h.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(h=>h.platform==="web")){Xs(e,r);return r.join("\n")}r.push(`**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`);r.push("");const s=e.rows.some(h=>h.thermalState);const o=s?" Thermal |":"";const i=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${i}-------|`);for(const h of e.rows){const $=s?` ${Js[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${$} \u274C FAILED (${h.failureReason}) |`);continue}const y=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"\u2713 clean";r.push(`| ${h.batchIndex} | ${h.caseName} | ${n(h.uiFps)}${t(h.deltaUiFps)} | ${n(h.jsFps)}${t(h.deltaJsFps)} | ${n(h.cpu)} | ${n(h.memMb)} | ${n(h.jsJank,0)} | ${n(h.uiJank,0)} |${$} ${y} |`)}const a=pn(e.rows);if(a){r.push("");r.push(a)}const c=e.rows.filter(h=>h.thermalState==="serious"||h.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(h=>h.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`)}r.push("");const u=e.leaders;const l=[u.uiFps&&`UI FPS \u2192 **${u.uiFps}**`,u.jsFps&&`JS FPS \u2192 **${u.jsFps}**`,u.lowestCpu&&`lowest CPU \u2192 **${u.lowestCpu}**`,u.lowestMem&&`lowest mem \u2192 **${u.lowestMem}**`,u.fewestJank&&`fewest jank \u2192 **${u.fewestJank}**`].filter(Boolean).join(" \xB7 ");if(l)r.push(`**Per-metric best:** ${l}`);r.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);if(e.flagged.length){r.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(h=>`${h.caseName} (${h.flags.join("; ")})`).join(" \xB7 ")}`)}const d=e.rows.filter(h=>!h.failed&&(h.renderCommits!=null||h.topRenderers.length>0)).map(h=>{const $=h.topRenderers.map(w=>`${w.name} \xD7${w.renders} (${w.totalMs}ms${w.cause?`, ${w.cause}`:""})`).join(", ");const y=h.renderCommits!=null?`${h.renderCommits} commits`:"";const f=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[y,f,$].filter(Boolean).join(": ")}`});if(d.length){r.push("");r.push("**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream.");r.push(...d)}r.push("");r.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`);return r.join("\n")}function mn(e,n){const t=(r,s)=>({a:{avg:te(r.avg,1),p95:te(r.p95,1)},b:{avg:te(s.avg,1),p95:te(s.p95,1)},deltaAvg:te(s.avg-r.avg,1)});return{a:e.name,b:n.name,uiFps:t(e.stats.uiFps,n.stats.uiFps),jsFps:t(e.stats.jsFps,n.stats.jsFps),cpuUsage:t(e.stats.cpuUsage,n.stats.cpuUsage),memoryUsage:t(e.stats.memoryUsage,n.stats.memoryUsage),jsJankFrames:{a:e.stats.jsJankFrames,b:n.stats.jsJankFrames,delta:n.stats.jsJankFrames-e.stats.jsJankFrames},uiJankFrames:{a:e.stats.uiJankFrames,b:n.stats.uiJankFrames,delta:n.stats.uiJankFrames-e.stats.uiJankFrames},...Qs(e.renders,n.renders)}}function Qs(e,n){if(!e&&!n)return null;const t=r=>r??0;return{renders:{total:{a:t(e?.totalRenders),b:t(n?.totalRenders),delta:t(n?.totalRenders)-t(e?.totalRenders)},commits:{a:t(e?.totalCommits),b:t(n?.totalCommits),delta:t(n?.totalCommits)-t(e?.totalCommits)},wasted:{a:t(e?.wastedRenders),b:t(n?.wastedRenders),delta:t(n?.wastedRenders)-t(e?.wastedRenders)},causes:{a:e?.causes??{},b:n?.causes??{}}}}}function te(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function Xs(e,n){const t=(l,d=1)=>l==null?"\u2014":l.toFixed(d);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**No single winner \u2014 weigh the trade-offs.** This is a browser run. FPS is the main thread's frame rate (requestAnimationFrame); it can't see frames dropped later by the GPU or compositor. Long tasks % is time the main thread was blocked by tasks over 50 ms, not CPU use.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | Heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const d=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${d} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const h=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"\u2713 clean";n.push(`| ${l.batchIndex} | ${d} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${h} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const d=[l.loafBlockingMs!=null&&l.loafBlockingMs>0&&`slow frames blocked input ${l.loafBlockingMs} ms`,l.styleLayoutMs!=null&&l.styleLayoutMs>0&&`style + layout ${l.styleLayoutMs} ms`,l.forcedLayoutMs!=null&&l.forcedLayoutMs>0&&`forced layout ${l.forcedLayoutMs} ms`,l.worstInteractionMs!=null&&`slowest input ${l.worstInteractionMs} ms`,l.layoutShift!=null&&l.layoutShift>0&&`layout shift ${l.layoutShift}`,l.domNodes!=null&&`${l.domNodes} DOM nodes${l.domGrowth?` (+${l.domGrowth} over the runs)`:""}`,l.topScripts&&l.topScripts.length>0&&`top scripts: ${l.topScripts.slice(0,3).map(h=>`${h.name} ${h.ms} ms`).join(", ")}`].filter(Boolean);return d.length?`- **${l.caseName}**: ${d.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size):`);n.push(...s)}const o=Ys(e.rows);if(o.length){n.push("");n.push(...o)}const i=pn(e.rows);if(i){n.push("");n.push(i)}const a=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(a.length){n.push("");n.push(`**Not measured in this browser:** ${a.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.deviceMaxRefreshRate){n.push("");n.push(`Screen rate: ${e.deviceMaxRefreshRate} Hz. A frame gap over ${(1500/e.deviceMaxRefreshRate).toFixed(1)} ms missed at least one frame.`)}n.push("");const c=e.leaders;const u=[c.jsFps&&`FPS \u2192 **${c.jsFps}**`,c.fewestJank&&`fewest long frames \u2192 **${c.fewestJank}**`,c.fewestLongTasks&&`fewest long tasks \u2192 **${c.fewestLongTasks}**`,c.lowestMem&&`lowest heap \u2192 **${c.lowestMem}**`].filter(Boolean).join(" \xB7 ");if(u)n.push(`**Per-metric leaders:** ${u}`);if(e.flagged.length){n.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(l=>`${l.caseName} (${l.flags.join("; ")})`).join(" \xB7 ")}`)}n.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);n.push("");n.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Confirm finalists on a real phone browser: desktop numbers don't predict mobile GPU, memory or heat._`)}import{z as M}from"zod";import{z as Zs}from"zod";var k=e=>({content:[{type:"text",text:e}]});var m=e=>({isError:true,content:[{type:"text",text:e}]});var x=e=>e instanceof Error?e.message:String(e);var S=Zs.string().optional().describe("Target device id (auto-selected if one is connected).");function eo(e,n){return typeof e==="number"&&e>0?e:n}function to(e){return e?`${e}
|
|
2
|
+
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await on(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new ys(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=Ss({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await Os(this.url);try{As(this.broker.dashboardToken,pt(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=_s(this.url,{transports:["websocket"],query:{deviceName:dt},auth:a=>{const c=this.broker?.dashboardToken??xs(pt(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===Ts;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${pt(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=ie(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===Y){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-Hr;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==dt&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===Y){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return Y}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return sn(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=Is({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:Gr,recoveryProbeMs:Yt,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=Ms({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:Kr});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(ie(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(ie(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(ie(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=ie(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=ie(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(ie(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=ie(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??Qt;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>Yt){u.lastFullBudgetAt=Date.now()}else{o=Vr}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===Y?Y:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=ks();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==Y&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===Y?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==Y&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var ie=(e,n)=>`${e}::${n}`;function on(e,n=800){return new Promise(t=>{const r=$s.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function Os(e,n=20){for(let t=0;t<n;t++){if(await on(e,300))return;await Cs(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var Cs=e=>new Promise(n=>setTimeout(n,e));var me="7.0.49"?"7.0.49":"0.0.0-dev";var ce="@buoy-gg/mcp";import{promises as an}from"fs";import Fs from"os";import Es from"path";var Bs=24*60*60*1e3;var js=1500;var qs=`https://registry.npmjs.org/${ce}/latest`;var cn=Es.join(Fs.tmpdir(),"buoy-mcp-update-check.json");function Ps(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function Ns(){try{const e=await an.readFile(cn,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function Ls(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),js);try{const r=await fetch(qs,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await an.writeFile(cn,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function Us(e){const n=await Ns();if(n&&e-n.checkedAt<Bs)return n.latest;return Ls(e)}async function ln(e=Date.now()){if(me==="0.0.0-dev")return null;try{const n=await Us(e);if(n&&Ps(n,me)){return`A newer buoy-mcp is available (${n}; you're on ${me}). Update by re-running: npx -y ${ce}@latest init \u2014 then restart your editor.`}}catch{}return null}import{z as Q}from"zod";function Re(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function mt(e){return{isError:true,content:[{type:"text",text:e}]}}function un(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await ln();const r=n.listDevices();if(r.length===0){return Re({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return Re({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:Q.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return mt(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return Re({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:Q.string().describe("The tool id to read state from."),deviceId:Q.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:Q.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return Re({toolId:t,deviceId:o,snapshot:i})}catch(o){return mt(dn(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:Q.string().describe('The tool id, e.g. "perf-monitor".'),action:Q.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:Q.record(Q.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:Q.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:Q.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return Re({toolId:t,action:r,ok:true,result:c??null})}catch(a){return mt(dn(a))}})}function dn(e){return e instanceof Error?e.message:String(e)}var Se=(e,n,t)=>Math.max(n,Math.min(t,e));var zs="__batch_warmup__";var Js={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var xe={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function Ae(e,n,t){return e??n??t}function Le(e,n={}){const t=[];const r=(e.targetRoute||"").trim();if(!r)throw new Error("targetRoute is required (the screen to benchmark).");let s=(e.bounceRoute||"").trim();if(!s)s=r==="/"?"/_buoy_bounce":"/";if(s===r){throw new Error(`bounceRoute ("${s}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`)}if(!Array.isArray(e.cases)||e.cases.length===0){throw new Error("At least one case is required.")}const o=e.cases.map((d,h)=>{const $=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:$,params:d.params??{},route:d.route}});const i=Se(Math.round(Ae(e.runsPerCase,n.runsPerCase,xe.runsPerCase)),1,10);const a=Se(Math.round(Ae(e.coolDownMs,n.coolDownMs,xe.coolDownMs)),0,3e4);const c=Se(Math.round(Ae(e.discardWarmupRuns,n.discardWarmupRuns,xe.discardWarmupRuns)),0,i-1);const u=e.shuffleCases??n.shuffleCases??o.length>3;if(o.length>3&&u===false){t.push("shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it.")}if(i===1){t.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.")}const l={targetRoute:r,bounceRoute:s,perCaseDurationMs:Se(Math.round(Ae(e.perCaseDurationMs,n.perCaseDurationMs,xe.perCaseDurationMs)),1e3,6e4),settleMs:Se(Math.round(Ae(e.settleMs,n.settleMs,xe.settleMs)),0,1e4),navTimeoutMs:Se(Math.round(Ae(e.navTimeoutMs,n.navTimeoutMs,xe.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,discardWarmupCase:e.discardWarmupCase??n.discardWarmupCase??true,shuffleCases:u,reloadBetweenCases:e.reloadBetweenCases??n.reloadBetweenCases??false,captureRenders:e.captureRenders??n.captureRenders??true};return{config:l,warnings:t}}function hn(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=ft(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const i=[`**${n(e.perCaseDurationMs)} measured per page**`,t>1?`**${t} runs each** (median taken)`:`**1 run each** (no median \u2014 noisier)`,e.reloadBetweenCases?"**full reload between pages** (no state leaks across tests)":"no reload between pages",e.shuffleCases?"shuffled order (cancels thermal bias)":"fixed order",e.captureRenders!==false?"render capture on (per-component why-data)":"render capture off"];const a=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${i.join(", ")}. That's ~${n(a)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function ft(e){const n=1800;const t=e.settleMs+e.perCaseDurationMs+e.coolDownMs+n;let r=e.cases.length*(e.runsPerCase??1)*t;if(e.reloadBetweenCases)r+=e.cases.length*3e3;return r}function Ws(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.uiFps!=null&&t&&e.uiFps<t*.95){n.push("UI thread drops frames")}else if(e.uiJank!=null&&e.uiJank>=2){n.push("UI jank spikes")}if(e.cpu!=null&&e.cpu>=90)n.push("CPU near cap");if(e.jsJank!=null&&e.jsJank>=5)n.push("JS thread stalls");return n}function Gs(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Main thread drops frames")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}if(e.frameGapMaxMs!=null&&e.frameGapMaxMs>=100){n.push(`Freeze of ${Math.round(e.frameGapMaxMs)} ms`)}if(e.longTaskPct!=null&&e.longTaskPct>=20)n.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50){n.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`)}if(e.styleLayoutMs!=null&&e.styleLayoutMs>=100){n.push(`Style and layout in slow frames (${Math.round(e.styleLayoutMs)} ms)`)}if(e.worstInteractionMs!=null&&e.worstInteractionMs>=200){n.push(`Slow response to input (${Math.round(e.worstInteractionMs)} ms)`)}if(e.layoutShift!=null&&e.layoutShift>=.1)n.push(`Layout shifts (${e.layoutShift})`);if(e.domGrowth!=null&&e.domGrowth>0)n.push(`DOM grows each run (+${e.domGrowth} nodes)`);return n}function Vs(e){const n=e.filter(r=>!r.batchFailureReason).sort((r,s)=>(r.runIndex??0)-(s.runIndex??0)).map(r=>r.webRun?.domNodesStart).filter(r=>typeof r==="number");if(n.length<3)return null;for(let r=1;r<n.length;r++)if(n[r]<=n[r-1])return null;const t=n[n.length-1]-n[0];return t>=Math.max(50,n[0]*.05)?t:null}function Ks(e,n){const t=e.find(s=>s.isBaseline);if(t)return t;const r=n?.[0]?.name;if(r){const s=e.filter(o=>o.caseName===r);if(s.length===1)return s[0]}return e[0]}function Ue(e,n,t,r=[]){const s=e.filter(p=>p.batchId===n);const o=new Map(r.map(p=>[p.index,p.reason]));const i=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,g)=>p-g);const a=p=>{const g=s.filter(v=>(v.batchIndex??0)===p);if(g.length===0)return void 0;return g.find(v=>v.isMedianRun===true)??g.find(v=>v.runIndex===0||v.runIndex===void 0)??g[0]};const c=p=>typeof p==="number"?p:null;const u=i.map(p=>{const g=a(p);const v=s.filter(G=>(G.batchIndex??0)===p);const b=v.length;const A=v.filter(G=>!G.batchFailureReason);const T=G=>G.webRun?G.jsFpsAvg:G.uiFpsAvg;const R=A.map(T).filter(G=>typeof G==="number");const B=g?.name??t?.[p]?.name??`case ${p+1}`;const lt=s.some(G=>(G.batchIndex??0)===p&&G.isBaseline===true);const O=o.get(p)??g?.batchFailureReason;const C=Boolean(O)||!g;const F={batchIndex:p,caseName:B,params:g?.params??{},runs:b,failed:C,...lt?{isBaseline:true}:{},failureReason:C?O??"no run recorded":void 0,uiFps:c(g?.uiFpsAvg),jsFps:c(g?.jsFpsAvg),cpu:c(g?.cpuAvg),memMb:c(g?.memMaxMb),jsJank:c(g?.jsJankFrames),uiJank:c(g?.uiJankFrames),deviceMaxRefreshRate:c(g?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:A.length,fpsMin:R.length?te(Math.min(...R),1):null,fpsMax:R.length?te(Math.max(...R),1):null,...g?.thermalState?{thermalState:g.thermalState}:{},renderCommits:c(g?.renderCommits),renderWasted:g?.renderWasted,topRenderers:g?.topRenderers??[]};const E=g?.webRun;if(!E)return{...F,flags:C?[]:Ws(F)};const Gt=[...E.longTasksSupported?[]:["long tasks"],...E.heapSupported?[]:["JS heap"],...E.loaf===null?["long animation frames"]:[]];const Vt={...F,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:E.heapSupported?F.memMb:null,deviceMaxRefreshRate:Math.max(F.deviceMaxRefreshRate??0,E.refreshHz??0)||null,longTaskPct:E.longTaskPct,frameGapP95Ms:E.frameGapP95Ms,frameGapMaxMs:E.frameGapMaxMs,longFrames:E.longFrames,...Gt.length?{missingMetrics:Gt}:{},loafBlockingMs:E.loaf?.blockingMs??null,styleLayoutMs:E.loaf?.styleLayoutMs??null,forcedLayoutMs:E.loaf?.forcedLayoutMs??null,topScripts:E.loaf?.topScripts??[],worstInteractionMs:E.interactions?.worstMs??null,layoutShift:E.layoutShift??null,domNodes:E.domNodesEnd??null,domGrowth:Vs(v),pageLoad:E.pageLoad??null};return{...Vt,flags:C?[]:Gs({...Vt,frames:E.frames})}}).filter(p=>p.caseName!==zs).map((p,g)=>({...p,batchIndex:g}));const l=Ks(u,t);if(l){l.isBaseline=true;for(const p of u){if(p===l)continue;if(p.uiFps!=null&&l.uiFps!=null)p.deltaUiFps=te(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=te(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,g)=>{let v;let b=NaN;for(const A of d){const T=p(A);if(T==null)continue;if(v===void 0||g(T,b)){v=A;b=T}}return v?.caseName};const $=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const y={uiFps:h(p=>p.uiFps,(p,g)=>p>g),jsFps:h(p=>p.jsFps,(p,g)=>p>g),lowestCpu:h(p=>p.cpu,(p,g)=>p<g),lowestMem:h(p=>p.memMb,(p,g)=>p<g),fewestJank:h($,(p,g)=>p<g),fewestLongTasks:h(p=>p.longTaskPct??null,(p,g)=>p<g)};const f=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const w=d.filter(p=>p.flags.length===0).sort((p,g)=>(g.uiFps??g.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(g.cpu??g.longTaskPct??Infinity)||(p.memMb??Infinity)-(g.memMb??Infinity)).map(p=>p.caseName);return{batchId:n,baselineName:l?.caseName??null,deviceMaxRefreshRate:d.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:y,flagged:f,balanced:w}}var Hs=2e4;function Ys(e){const n=e.filter(r=>!r.failed);if(n.length===0||!n.every(r=>r.pageLoad&&r.pageLoad.pageAgeMs<Hs)){return[]}const t=r=>r==null?"\u2014":String(Math.round(r));return[`**Page load** (each case started on a fresh page load):`,"",`| Case | TTFB ms | FCP ms | LCP ms | DOM ready ms | Script KB | Total KB |`,`|------|--------:|-------:|-------:|-------------:|----------:|---------:|`,...n.map(r=>{const s=r.pageLoad;return`| ${r.caseName} | ${t(s.ttfbMs)} | ${t(s.fcpMs)} | ${t(s.lcpMs)} | ${t(s.domContentLoadedMs)} | ${t(s.scriptKb)} | ${t(s.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function pn(e){const n=o=>o.platform==="web"?o.jsFps:o.uiFps;const t=e.filter(o=>!o.failed&&n(o)!=null&&o.fpsMin!=null&&o.fpsMax!=null).sort((o,i)=>(n(i)??0)-(n(o)??0));if(t.length<2)return null;const[r,s]=t;if((r.validRuns??0)<2||(s.validRuns??0)<2){return`**Too few runs to separate the top cases.** ${r.caseName} and ${s.caseName} each need 2+ valid runs before their FPS gap means anything.`}if((s.fpsMax??0)>=(r.fpsMin??0)){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap across runs. Run more repeats before picking between them.`}return null}function ze(e){const n=(h,$=1)=>h==null?"\u2014":h.toFixed($);const t=h=>h==null?"":h>0?` (+${h.toFixed(1)})`:` (${h.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(h=>h.platform==="web")){Xs(e,r);return r.join("\n")}r.push(`**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`);r.push("");const s=e.rows.some(h=>h.thermalState);const o=s?" Thermal |":"";const i=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${i}-------|`);for(const h of e.rows){const $=s?` ${Js[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${$} \u274C FAILED (${h.failureReason}) |`);continue}const y=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"\u2713 clean";r.push(`| ${h.batchIndex} | ${h.caseName} | ${n(h.uiFps)}${t(h.deltaUiFps)} | ${n(h.jsFps)}${t(h.deltaJsFps)} | ${n(h.cpu)} | ${n(h.memMb)} | ${n(h.jsJank,0)} | ${n(h.uiJank,0)} |${$} ${y} |`)}const a=pn(e.rows);if(a){r.push("");r.push(a)}const c=e.rows.filter(h=>h.thermalState==="serious"||h.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(h=>h.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`)}r.push("");const u=e.leaders;const l=[u.uiFps&&`UI FPS \u2192 **${u.uiFps}**`,u.jsFps&&`JS FPS \u2192 **${u.jsFps}**`,u.lowestCpu&&`lowest CPU \u2192 **${u.lowestCpu}**`,u.lowestMem&&`lowest mem \u2192 **${u.lowestMem}**`,u.fewestJank&&`fewest jank \u2192 **${u.fewestJank}**`].filter(Boolean).join(" \xB7 ");if(l)r.push(`**Per-metric best:** ${l}`);r.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);if(e.flagged.length){r.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(h=>`${h.caseName} (${h.flags.join("; ")})`).join(" \xB7 ")}`)}const d=e.rows.filter(h=>!h.failed&&(h.renderCommits!=null||h.topRenderers.length>0)).map(h=>{const $=h.topRenderers.map(w=>`${w.name} \xD7${w.renders} (${w.totalMs}ms${w.cause?`, ${w.cause}`:""})`).join(", ");const y=h.renderCommits!=null?`${h.renderCommits} commits`:"";const f=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[y,f,$].filter(Boolean).join(": ")}`});if(d.length){r.push("");r.push("**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream.");r.push(...d)}r.push("");r.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`);return r.join("\n")}function mn(e,n){const t=(r,s)=>({a:{avg:te(r.avg,1),p95:te(r.p95,1)},b:{avg:te(s.avg,1),p95:te(s.p95,1)},deltaAvg:te(s.avg-r.avg,1)});return{a:e.name,b:n.name,uiFps:t(e.stats.uiFps,n.stats.uiFps),jsFps:t(e.stats.jsFps,n.stats.jsFps),cpuUsage:t(e.stats.cpuUsage,n.stats.cpuUsage),memoryUsage:t(e.stats.memoryUsage,n.stats.memoryUsage),jsJankFrames:{a:e.stats.jsJankFrames,b:n.stats.jsJankFrames,delta:n.stats.jsJankFrames-e.stats.jsJankFrames},uiJankFrames:{a:e.stats.uiJankFrames,b:n.stats.uiJankFrames,delta:n.stats.uiJankFrames-e.stats.uiJankFrames},...Qs(e.renders,n.renders)}}function Qs(e,n){if(!e&&!n)return null;const t=r=>r??0;return{renders:{total:{a:t(e?.totalRenders),b:t(n?.totalRenders),delta:t(n?.totalRenders)-t(e?.totalRenders)},commits:{a:t(e?.totalCommits),b:t(n?.totalCommits),delta:t(n?.totalCommits)-t(e?.totalCommits)},wasted:{a:t(e?.wastedRenders),b:t(n?.wastedRenders),delta:t(n?.wastedRenders)-t(e?.wastedRenders)},causes:{a:e?.causes??{},b:n?.causes??{}}}}}function te(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function Xs(e,n){const t=(l,d=1)=>l==null?"\u2014":l.toFixed(d);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**No single winner \u2014 weigh the trade-offs.** This is a browser run. FPS is the main thread's frame rate (requestAnimationFrame); it can't see frames dropped later by the GPU or compositor. Long tasks % is time the main thread was blocked by tasks over 50 ms, not CPU use.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | Heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const d=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${d} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const h=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"\u2713 clean";n.push(`| ${l.batchIndex} | ${d} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${h} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const d=[l.loafBlockingMs!=null&&l.loafBlockingMs>0&&`slow frames blocked input ${l.loafBlockingMs} ms`,l.styleLayoutMs!=null&&l.styleLayoutMs>0&&`style + layout ${l.styleLayoutMs} ms`,l.forcedLayoutMs!=null&&l.forcedLayoutMs>0&&`forced layout ${l.forcedLayoutMs} ms`,l.worstInteractionMs!=null&&`slowest input ${l.worstInteractionMs} ms`,l.layoutShift!=null&&l.layoutShift>0&&`layout shift ${l.layoutShift}`,l.domNodes!=null&&`${l.domNodes} DOM nodes${l.domGrowth?` (+${l.domGrowth} over the runs)`:""}`,l.topScripts&&l.topScripts.length>0&&`top scripts: ${l.topScripts.slice(0,3).map(h=>`${h.name} ${h.ms} ms`).join(", ")}`].filter(Boolean);return d.length?`- **${l.caseName}**: ${d.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size):`);n.push(...s)}const o=Ys(e.rows);if(o.length){n.push("");n.push(...o)}const i=pn(e.rows);if(i){n.push("");n.push(i)}const a=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(a.length){n.push("");n.push(`**Not measured in this browser:** ${a.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.deviceMaxRefreshRate){n.push("");n.push(`Screen rate: ${e.deviceMaxRefreshRate} Hz. A frame gap over ${(1500/e.deviceMaxRefreshRate).toFixed(1)} ms missed at least one frame.`)}n.push("");const c=e.leaders;const u=[c.jsFps&&`FPS \u2192 **${c.jsFps}**`,c.fewestJank&&`fewest long frames \u2192 **${c.fewestJank}**`,c.fewestLongTasks&&`fewest long tasks \u2192 **${c.fewestLongTasks}**`,c.lowestMem&&`lowest heap \u2192 **${c.lowestMem}**`].filter(Boolean).join(" \xB7 ");if(u)n.push(`**Per-metric leaders:** ${u}`);if(e.flagged.length){n.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(l=>`${l.caseName} (${l.flags.join("; ")})`).join(" \xB7 ")}`)}n.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);n.push("");n.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Confirm finalists on a real phone browser: desktop numbers don't predict mobile GPU, memory or heat._`)}import{z as M}from"zod";import{z as Zs}from"zod";var k=e=>({content:[{type:"text",text:e}]});var m=e=>({isError:true,content:[{type:"text",text:e}]});var x=e=>e instanceof Error?e.message:String(e);var S=Zs.string().optional().describe("Target device id (auto-selected if one is connected).");function eo(e,n){return typeof e==="number"&&e>0?e:n}function to(e){return e?`${e}
|
|
3
3
|
|
|
4
4
|
`:""}function fn(e){return`No ${e.toolId} result from the device. The ${e.toolId} tool may be inactive, or the app needs a newer Buoy (${e.mcpTool} requires the ${e.action} action).`}function Oe(e,n){return async t=>{try{const r=e.requireProDevice(t.deviceId);return await n(t,r)}catch(r){return m(x(r))}}}var U="perf-monitor";var Ce=1500;var Fe=e=>new Promise(n=>setTimeout(n,e));var gn=e=>({content:[{type:"text",text:JSON.stringify(e,null,2)}]});var no=M.object({name:M.string().describe("Display name for this variant (becomes the saved run's name)."),params:M.record(M.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),route:M.string().optional().describe("Optional per-case route override; defaults to targetRoute.")});async function gt(e,n){try{const t=await e.callAction(U,"getAutomationConfig",{},n);if(!t)return{};const{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}}catch{return{}}}async function ne(e,n){const{payload:t}=await e.getSnapshot(U,n,{timeoutMs:4e3});return t}function vt(e){const n=new Set;for(const t of e??[])if(t.batchId)n.add(t.batchId);return n}function vn(e,n){return(e??[]).filter(t=>t.batchId===n).length}function ro(e){let n=null;for(const t of e??[]){if(t.batchId&&(!n||t.createdAt>n.at))n={id:t.batchId,at:t.createdAt}}return n?.id??null}function fe(e,n){const t=new Map;for(const r of e??[]){if(r.batchId===n&&r.batchFailureReason&&!t.has(r.batchIndex??0)){t.set(r.batchIndex??0,r.batchFailureReason)}}return[...t.entries()].map(([r,s])=>({index:r,reason:s}))}function so(e){const n=e.cases.length+(e.discardWarmupCase===true?1:0);return n*(e.runsPerCase??1)}var oo=1e4;function ao(e){return!!e&&e!=="idle"&&e!=="done"&&e!=="cancelled"}async function wn(e,n,t,r=oo){const s=Date.now();while(Date.now()-s<r){const o=await ne(e,n).catch(()=>null);if(o&&!ao(o.live?.automation?.phase))return true;await Fe(Ce)}t(`automation still busy after ${Math.round(r/1e3)}s`);return false}async function io(e,n,t){const{preBatchIds:r,expectedRuns:s,maxWaitMs:o,log:i}=t;const a=Date.now();let c="";let u=false;let l=0;while(Date.now()-a<o){let $;try{$=await ne(e,n)}catch{await Fe(Ce);continue}const y=$.index??[];const f=$.live?.automation?.phase??"idle";const w=$.live?.automationCompleted;if(w?.phase==="cancelled"&&!r.has(w.batchId)){return{batchId:w.batchId,failures:[],cancelled:true}}if(w?.phase==="done"&&!r.has(w.batchId)){return{batchId:w.batchId,failures:w.failures??fe(y,w.batchId)}}if(f!=="idle")u=true;if(f!==c){c=f;i(`automation phase: ${f}`)}const p=[...vt(y)].find(g=>!r.has(g));if(p){const g=vn(y,p);if(s>0&&g>=s){await wn(e,n,i);i(`batch ${p} complete (${g}/${s} runs saved)`);return{batchId:p,failures:fe(y,p)}}if(f==="idle"&&u&&g>0){if(++l>=3){i(`batch ${p} settled (${g} runs, automation idle)`);return{batchId:p,failures:fe(y,p)}}}else{l=0}}await Fe(Ce)}const d=await ne(e,n).catch(()=>null);const h=d?[...vt(d.index)].find($=>!r.has($)):void 0;if(h&&d)return{batchId:h,failures:fe(d.index,h),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${c||"idle"}). Check get_automation_status \u2014 the device may not have navigated.`)}function bn(e,n){e.registerTool("run_benchmark_batch",{description:"Run a perf-monitor benchmark batch and return per-metric leaders, risk flags and a comparison of available case results. Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",inputSchema:{targetRoute:M.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),cases:M.array(no).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),bounceRoute:M.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),runsPerCase:M.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),perCaseDurationMs:M.number().optional().describe("How long to record each run. Default 5000."),settleMs:M.number().optional().describe("Idle gap after navigation before recording. Default 600."),coolDownMs:M.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),navTimeoutMs:M.number().optional().describe("Max wait for navigation per run. Default 5000."),discardWarmupRuns:M.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),shuffleCases:M.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),reloadBetweenCases:M.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),captureRenders:M.boolean().optional().describe("Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds instrumentation overhead that can vary with the workload. Default true."),deviceId:M.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),maxWaitMs:M.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")}},async t=>{const r=s=>process.stderr.write(`[buoy-mcp] ${s}
|
|
5
5
|
`);try{const s=n.requireProDevice(t.deviceId);const o=await gt(n,s);const{config:i,warnings:a}=Le(t,o);const c=ft(i);const u=t.maxWaitMs??c*3+6e4;const l=so(i);if(!await wn(n,s,r)){return m("Another benchmark batch is still running on this device. Wait for it to finish, or cancel it (call_action perf-monitor cancelAutomation), then retry.")}await n.callAction(U,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(U,"setEnabled",{enabled:true},s).catch(()=>{});await n.callAction(U,"refreshIndex",{},s).catch(()=>{});const d=await ne(n,s);const h=vt(d.index);r(`starting batch: ${i.cases.length} cases \xD7 ${i.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(U,"startAutomation",{config:i},s);const $=await io(n,s,{preBatchIds:h,expectedRuns:l,maxWaitMs:u,log:r});if($.cancelled){await n.callAction(U,"acknowledgeAutomation",{},s).catch(()=>{});return m(`Batch ${$.batchId} was cancelled before completing.`)}await n.callAction(U,"refreshIndex",{},s).catch(()=>{});const y=await ne(n,s);const f=Ue(y.index??[],$.batchId,i.cases,$.failures);await n.callAction(U,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(U,"setEnabled",{enabled:false},s).catch(()=>{});if(f.rows.length===0){const g=n.connectedDevices().some(b=>b.deviceId===s);const v=!g?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:$.timedOut?`No runs were saved before the wait cap (${Math.round(u/1e3)}s). On web, a hidden or background tab pauses the run: keep the tab in front. Check get_automation_status for the device's phase.`:`The batch finished without saving any runs. Check get_automation_status and the device console.`;return m(`Benchmark batch ${$.batchId||"(no id)"} saved no runs. ${v}`)}const w=ze(f);const p={batchId:$.batchId,leaders:f.leaders,balanced:f.balanced,flagged:f.flagged,rows:f.rows,failures:$.failures,warnings:a,estimateMs:c,...$.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return k(`${w}
|
package/dist/cli.js
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
};
|
|
64
64
|
requestAnimationFrame(tick);
|
|
65
65
|
})`}});var webBench_exports={};__export(webBench_exports,{PLAYWRIGHT_MISSING:()=>PLAYWRIGHT_MISSING,aggregateCase:()=>aggregateCase,caseUrl:()=>caseUrl,cdpDeltas:()=>cdpDeltas,formatWebBenchMarkdown:()=>formatWebBenchMarkdown,oneLine:()=>oneLine,percentile:()=>percentile,runWebBench:()=>runWebBench,shuffled:()=>shuffled,snapRefreshRate:()=>snapRefreshRate,summarizeProbe:()=>summarizeProbe,webBenchFlags:()=>webBenchFlags});function percentile(e,n){if(e.length===0)return null;const t=[...e].sort((s,o)=>s-o);const r=Math.ceil(n/100*t.length)-1;return t[Math.min(t.length-1,Math.max(0,r))]}function snapRefreshRate(e){for(const n of COMMON_RATES)if(Math.abs(e-n)/n<=.08)return n;return Math.max(1,Math.round(e))}function shuffled(e,n){let t=n>>>0;const r=()=>{t=t+1831565813>>>0;let o=t;o=Math.imul(o^o>>>15,o|1);o^=o+Math.imul(o^o>>>7,o|61);return((o^o>>>14)>>>0)/4294967296};const s=[...e];for(let o=s.length-1;o>0;o--){const i=Math.floor(r()*(o+1));[s[o],s[i]]=[s[i],s[o]]}return s}function caseUrl(e,n,t){const r=new URL(e);const s=n.path??t;if(s)r.pathname=s.startsWith("/")?s:`${r.pathname.replace(/\/$/,"")}/${s}`;for(const[o,i]of Object.entries(n.params??{}))r.searchParams.set(o,i);return r.toString()}function summarizeProbe(e,n){const{start:t,end:r}=e;const s=Math.max(1,r-t);const o=[];for(let v=1;v<e.frames.length;v++)o.push(e.frames[v]-e.frames[v-1]);const i=1e3/n;const a=percentile(o,95);let c=null;if(e.longTasks){let v=0;for(const m of e.longTasks){const g=Math.min(m.end,r)-Math.max(m.start,t);if(g>0)v+=g}c=r1(Math.min(100,v/s*100))}let u=null;let l=null;let d=null;let h=[];if(e.loaf){let v=0;let m=0;let g=0;const p=new Map;for(const f of e.loaf){if(f.startTime<t||f.startTime>r)continue;v+=f.blockingDuration;if(f.styleAndLayoutStart>f.startTime)m+=Math.max(0,f.startTime+f.duration-f.styleAndLayoutStart);for(const w of f.scripts){g+=w.forced;const b=p.get(w.name)??{ms:0,count:0};b.ms+=w.duration;b.count++;p.set(w.name,b)}}u=r1(v);l=r1(m);d=r1(g);h=[...p.entries()].map(([f,w])=>({name:f,ms:r1(w.ms),count:w.count})).sort((f,w)=>w.ms-f.ms).slice(0,5)}let y=null;if(e.shifts){let v=0;for(const m of e.shifts)if(!m.hadRecentInput&&m.startTime>=t&&m.startTime<=r)v+=m.value;y=Math.round(v*1e3)/1e3}return{fps:e.frames.length>1?r1((e.frames.length-1)/s*1e3):null,frameGapP95Ms:a==null?null:r1(a),frameGapMaxMs:o.length?r1(Math.max(...o)):null,longFrames:o.filter(v=>v>i*1.5).length,longTaskPct:c,loafBlockingMs:u,styleLayoutMs:l,forcedLayoutMs:d,topScripts:h,layoutShift:y,domNodesStart:e.domStart,domNodesEnd:e.domEnd}}function cdpDeltas(e,n,t){const r=i=>i in e&&i in n?n[i]-e[i]:null;const s=i=>{const a=r(i);return a==null?null:r1(a*1e3)};const o=s("TaskDuration");return{mainThreadPct:o==null?null:r1(Math.min(100,o/Math.max(1,t)*100)),scriptMs:s("ScriptDuration"),styleMs:s("RecalcStyleDuration"),layoutMs:s("LayoutDuration"),layoutCount:r("LayoutCount"),styleCount:r("RecalcStyleCount"),heapMb:"JSHeapUsedSize"in n?r1(n.JSHeapUsedSize/(1024*1024)):null}}function webBenchFlags(e,n,t){const r=[];if(e.fps!=null&&e.fps<n*.95)r.push("Main thread drops frames");if(e.frameGapMaxMs!=null&&e.frameGapMaxMs>=100)r.push(`Freeze of ${Math.round(e.frameGapMaxMs)} ms`);if(e.mainThreadPct!=null&&e.mainThreadPct>=80)r.push("Main thread near full");if(e.longTaskPct!=null&&e.longTaskPct>=20)r.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50)r.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`);if(e.layoutShift!=null&&e.layoutShift>=.1)r.push(`Layout shifts (${e.layoutShift})`);if(t)r.push(`DOM grows each run (+${t} nodes)`);return r}function aggregateCase(e,n){const t=e.filter(a=>!a.failed&&a.fps!=null);const r=[...t].sort((a,c)=>(a.fps??0)-(c.fps??0));const s=r.length?r[Math.floor((r.length-1)/2)]:null;const o=[...t].sort((a,c)=>a.round-c.round).map(a=>a.domNodesEnd).filter(a=>a!=null);let i=null;if(o.length>=3&&o.every((a,c)=>c===0||a>o[c-1])){const a=o[o.length-1]-o[0];if(a>=Math.max(50,o[0]*.05))i=a}return{caseName:e[0]?.caseName??"",cpu:e[0]?.cpu??1,validRuns:t.length,runs:e,median:s,fpsMin:r.length?r[0].fps:null,fpsMax:r.length?r[r.length-1].fps:null,domGrowth:i,flags:s?webBenchFlags(s,n,i):[]}}async function loadChromium(){const e=(0,import_node_module.createRequire)((0,import_node_path5.join)(process.cwd(),"noop.js"));for(const n of["playwright","playwright-core"]){try{const t=await import((0,import_node_url.pathToFileURL)(e.resolve(n)).href);const r=t.chromium??t.default?.chromium;if(r)return r}catch{}}throw new Error(PLAYWRIGHT_MISSING)}function oneLine(e){return e.replace(/\u001b\[[0-9;]*m/g,"").split("\n")[0].trim().slice(0,160)}async function readMetrics(e){const{metrics:n}=await e.send("Performance.getMetrics");return Object.fromEntries(n.map(t=>[t.name,t.value]))}async function runWebBench(e,n=()=>{}){if(e.cases.length===0)throw new Error("Give at least one case.");const t=Math.max(1,Math.min(10,e.runsPerCase??3));const r=Math.max(500,e.durationMs??5e3);const s=Math.max(0,e.settleMs??1e3);const o=(e.cpuSlowdown?.length?e.cpuSlowdown:[1]).map(v=>Math.max(1,v));let i=e.refreshHz??60;const a={width:e.viewport?.width??1280,height:e.viewport?.height??800,deviceScaleFactor:e.viewport?.deviceScaleFactor??1};const c=[];const u=[];if(t<2)c.push("runsPerCase=1 gives no spread; use 3 or more before choosing.");const l=await loadChromium();const d=await l.launch({headless:!e.headed});const h=[];try{const v=await d.newContext({viewport:{width:a.width,height:a.height},deviceScaleFactor:a.deviceScaleFactor});const m=async(g,p,f,w)=>{const b=await v.newPage();const _=[];b.on("pageerror",$=>_.push(String($?.message??$)));const k={caseName:g.name,cpu:p,round:f,fps:null,frameGapP95Ms:null,frameGapMaxMs:null,longFrames:0,longTaskPct:null,loafBlockingMs:null,styleLayoutMs:null,forcedLayoutMs:null,topScripts:[],layoutShift:null,domNodesStart:null,domNodesEnd:null,mainThreadPct:null,scriptMs:null,styleMs:null,layoutMs:null,layoutCount:null,styleCount:null,heapMb:null,pageErrors:_};try{const $=await v.newCDPSession(b);await $.send("Performance.enable",{timeDomain:"timeTicks"});if(p>1)await $.send("Emulation.setCPUThrottlingRate",{rate:p});const A=await b.goto(caseUrl(e.url,g,e.path),{waitUntil:"load",timeout:3e4});if(A&&A.status()>=400)return{...k,failed:`HTTP ${A.status()}`};if(e.readySelector)await b.waitForSelector(e.readySelector,{state:"attached",timeout:15e3});await sleep2(s);if(w){await $.send("Tracing.start",{transferMode:"ReturnAsStream",categories:["devtools.timeline","disabled-by-default-devtools.timeline","disabled-by-default-devtools.timeline.frame","blink.user_timing","loading","latencyInfo","v8.execute"].join(",")})}const R=await readMetrics($);const x=Date.now();await b.evaluate(START_PROBE_JS);await sleep2(r);const S=await b.evaluate(STOP_PROBE_JS);const T=await readMetrics($);const M=Date.now()-x;if(w){const O=new Promise(D=>$.on("Tracing.tracingComplete",C=>D(C.stream)));await $.send("Tracing.end");const z=await O;let I="";for(;;){const D=await $.send("IO.read",{handle:z});I+=D.base64Encoded?Buffer.from(D.data,"base64").toString("utf8"):D.data;if(D.eof)break}await $.send("IO.close",{handle:z});(0,import_node_fs5.writeFileSync)(w,I);u.push(w)}if(!S)return{...k,failed:"probe returned nothing (page navigated away?)"};if(S.hidden)return{...k,failed:"page was hidden during the run"};return{...k,...summarizeProbe(S,i),...cdpDeltas(R,T,M)}}catch($){return{...k,failed:oneLine($?.message??String($))}}finally{await b.close().catch(()=>{})}};if(e.refreshHz==null){const g=await v.newPage();try{await g.goto("about:blank");const p=await g.evaluate(MEASURE_REFRESH_JS);i=snapRefreshRate(p);n(`idle frame rate ${p.toFixed(1)} \u2192 ${i} Hz`)}catch{c.push("Couldn't measure the idle frame rate; frame budgets assume 60 Hz.")}finally{await g.close().catch(()=>{})}}n("warmup load");await m(e.cases[0],1,-1);for(const g of o){for(let p=0;p<t;p++){const f=shuffled(e.cases,2654435769^p*7919+g);for(const w of f){n(`cpu ${g}x \xB7 round ${p+1}/${t} \xB7 ${w.name}`);h.push(await m(w,g,p))}}}if(e.traceDir){(0,import_node_fs5.mkdirSync)(e.traceDir,{recursive:true});for(const g of e.cases){const p=(0,import_node_path5.join)(e.traceDir,`${g.name.replace(/[^\w.-]+/g,"_")}.json`);n(`trace \xB7 ${g.name}`);await m(g,o[0],-1,p)}}await v.close()}finally{await d.close().catch(()=>{})}const y=[];for(const v of o){for(const m of e.cases){y.push(aggregateCase(h.filter(g=>g.cpu===v&&g.caseName===m.name),i))}}if(h.some(v=>v.pageErrors.length))c.push("Some runs threw page errors; check them before trusting those cases.");return{url:e.url,browser:`Chromium ${d.version()}${e.headed?"":" (headless)"}`,viewport:a,refreshHz:i,durationMs:r,runsPerCase:t,results:y,traces:u,warnings:c}}function formatWebBenchMarkdown(e){const n=(s,o=1)=>s==null?"\u2014":s.toFixed(o);const t=[];t.push(`### Web benchmark \xB7 ${e.url}`);t.push(`${e.browser} \xB7 ${e.viewport.width}\xD7${e.viewport.height} @${e.viewport.deviceScaleFactor}x \xB7 ${e.runsPerCase} runs \xD7 ${e.durationMs/1e3}s per case, fresh page each run, case order shuffled each round.`);t.push("");t.push(`**No single winner \u2014 weigh the trade-offs.** FPS and frame gaps come from the page's main thread. Main thread % is Chrome's own task time for the run. Desktop numbers, even with CPU slowdown, don't stand in for a real phone's GPU, memory or heat.`);const r=Array.from(new Set(e.results.map(s=>s.cpu)));for(const s of r){t.push("");t.push(s===1?`#### No CPU slowdown`:`#### CPU slowed ${s}x`);t.push("");t.push(`| Case | FPS (range) | p95 gap ms | Worst gap ms | Long frames | Main thread % | Script ms | Style ms | Layout ms | Layouts | DOM nodes | Heap MB | Flags |`);t.push(`|------|------------:|-----------:|-------------:|------------:|--------------:|----------:|---------:|----------:|--------:|----------:|--------:|-------|`);for(const a of e.results.filter(c=>c.cpu===s)){const c=a.median;if(!c){const d=a.runs.find(h=>h.failed)?.failed??"no valid runs";t.push(`| ${a.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${d}) |`);continue}const u=a.fpsMin!=null&&a.fpsMax!=null&&a.validRuns>1?` (${n(a.fpsMin)}\u2013${n(a.fpsMax)})`:"";const l=a.flags.length?`\u26A0\uFE0F ${a.flags.join("; ")}`:"\u2713 clean";t.push(`| ${a.caseName} | ${n(c.fps)}${u} | ${n(c.frameGapP95Ms)} | ${n(c.frameGapMaxMs)} | ${c.longFrames} | ${n(c.mainThreadPct)} | ${n(c.scriptMs,0)} | ${n(c.styleMs,0)} | ${n(c.layoutMs,0)} | ${n(c.layoutCount,0)} | ${n(c.domNodesEnd,0)} | ${n(c.heapMb)} | ${l} |`)}const o=e.results.filter(a=>a.cpu===s&&a.median?.fps!=null).sort((a,c)=>c.median.fps-a.median.fps);if(o.length>=2&&o[0].validRuns>1&&o[1].validRuns>1&&(o[1].fpsMax??0)>=(o[0].fpsMin??0)){t.push("");t.push(`**Too close to call:** ${o[0].caseName} and ${o[1].caseName} overlap across runs. Use more runs before picking.`)}const i=e.results.filter(a=>a.cpu===s&&a.median&&a.median.topScripts.length).map(a=>`- **${a.caseName}**: ${a.median.topScripts.slice(0,3).map(c=>`${c.name} ${c.ms} ms`).join(", ")}`);if(i.length){t.push("");t.push(`Scripts in slow frames (Long Animation Frames):`);t.push(...i)}}if(e.traces.length){t.push("");t.push(`Chrome traces (open in DevTools \u2192 Performance \u2192 Load profile): ${e.traces.join(", ")}`)}if(e.warnings.length){t.push("");t.push(...e.warnings.map(s=>`\u26A0\uFE0F ${s}`))}return t.join("\n")}var import_node_module,import_node_path5,import_node_fs5,import_node_url,r1,COMMON_RATES,PLAYWRIGHT_MISSING,sleep2;var init_webBench=__esm({"src/webBench/webBench.ts"(){"use strict";import_node_module=require("module");import_node_path5=require("path");import_node_fs5=require("fs");import_node_url=require("url");init_inPageProbe();r1=e=>Math.round(e*10)/10;COMMON_RATES=[30,60,75,90,120,144,165,240];PLAYWRIGHT_MISSING="Playwright isn't installed in this project. Install it, then run again:\n npm i -D playwright && npx playwright install chromium";sleep2=e=>new Promise(n=>setTimeout(n,e))}});var import_stdio=require("@modelcontextprotocol/sdk/server/stdio.js");var import_node_fs=require("fs");var import_node_path=require("path");var import_node_os=require("os");var import_node_crypto=require("crypto");var import_headless=require("@buoy-gg/license/headless");var KEY_NAMES=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function readProjectKey(e,n=process.env){for(const t of KEY_NAMES)if(n[t]!==void 0)return(0,import_headless.normalizeLicenseKey)(n[t])||null;for(const t of[".env.development.local",".env.local",".env"]){let r;try{r=(0,import_node_fs.readFileSync)((0,import_node_path.join)(e,t),"utf8")}catch{continue}const s=new Map;for(const o of r.split(/\r?\n/)){const i=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!i)continue;const a=i[2].trim();const c=a.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(i[1],c?c[2]:a.split("#")[0].trim())}for(const o of KEY_NAMES)if(s.has(o))return(0,import_headless.normalizeLicenseKey)(s.get(o))||null}return null}var NodeAccount=class{constructor(e,n={}){this.changed=e;this.key=null;this.cache=null;this.timer=null;this.unsubscribeSession=null;this.generation=0;this.busy=false;this.nextCheck=0;this.retry=0;this.lastAdmission=false;this.hasAccess=()=>this.hasKeyAccess()||this.session?.getStatus().state==="active";this.lastSessionSync=0;this.projectDir=(0,import_node_path.resolve)(n.projectDir??process.env.BUOY_PROJECT_DIR??process.cwd());const t=(0,import_node_crypto.createHash)("sha256").update(this.projectDir).digest("hex").slice(0,20);this.cacheFile=n.cacheFile??(0,import_node_path.join)((0,import_node_os.homedir)(),".buoy",`account-${t}.json`);this.session=n.session!==void 0?n.session:(0,import_headless.createNodeSession)({audience:import_headless.CLI_AUDIENCE,openBrowser:()=>{throw new Error("The MCP does not open a browser. Run `npx buoy login`.")}})}hasKeyAccess(){return(0,import_headless.resolveAccountAccess)({initialized:true,validating:false,key:this.key,tierSource:this.cache?.tier?"key":"none",verifiedAt:this.cache?.cachedAt??null}).status==="admitted"}start(){if(this.timer)return;if(this.session){this.unsubscribeSession=this.session.subscribe(()=>this.notify());void this.session.load().then(()=>this.notify(),()=>{})}try{const e=JSON.parse((0,import_node_fs.readFileSync)(this.cacheFile,"utf8"));if(e.valid&&(0,import_headless.verifyCacheSignature)(e))this.cache=e}catch{}void this.tick();this.timer=setInterval(()=>{void this.tick()},1e3);this.timer.unref()}stop(){++this.generation;this.unsubscribeSession?.();this.unsubscribeSession=null;if(this.timer)clearInterval(this.timer);this.timer=null;this.key=null;this.cache=null;this.notify()}notify(){const e=this.hasAccess();if(e===this.lastAdmission)return;this.lastAdmission=e;this.changed()}async tick(){if(this.session&&Date.now()-this.lastSessionSync>15e3){this.lastSessionSync=Date.now();void this.session.sync().catch(()=>{})}const e=readProjectKey(this.projectDir);if(e!==this.key){++this.generation;this.key=e;if(this.cache?.licenseKey!==e)this.cache=null;this.nextCheck=0;this.retry=0;if(!e)this.removeCache()}this.notify();if(!e||this.busy||Date.now()<this.nextCheck)return;if(this.cache&&this.hasKeyAccess()&&Date.now()-this.cache.lastValidatedAt<import_headless.LICENSE_LAUNCH_VALIDATION_TTL&&this.nextCheck===0){this.nextCheck=this.cache.lastValidatedAt+import_headless.LICENSE_LAUNCH_VALIDATION_TTL;return}this.busy=true;const n=this.generation;try{if((0,import_headless.isDevToken)(e)){const a=(0,import_headless.checkDevToken)(e);if(!a.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),a.claims.iat*1e3);const u=a.claims.tier==="free"?"free":"pro";this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`dev:${a.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(a.claims.exp*1e3).toISOString(),cachedAt:c,lastValidatedAt:Date.now()});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}if((0,import_headless.isServiceKey)(e)){const a=await(0,import_headless.exchangeServiceKey)(e,{origin:process.env.BUOY_SITE_URL||"https://buoy.gg"});if(n!==this.generation||readProjectKey(this.projectDir)!==e)return;if(!a.ok){if(a.reason==="rejected"){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL}else{this.nextCheck=Date.now()+(import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]??import_headless.LICENSE_REVALIDATION_INTERVAL)}return}const c=Date.now();const u=a.claims.exp*1e3;this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`service:${a.claims.sid}`,valid:true,tier:"pro",isPro:true,expiresAt:new Date(u).toISOString(),cachedAt:c,lastValidatedAt:c});this.persistCache();this.retry=0;this.nextCheck=Math.max(c+6e4,u-60*60*1e3);return}if((0,import_headless.isBuoyToken)(e)){const a=(0,import_headless.verifyFreeToken)(e);if(!a){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`token:${a.accountHash}`,valid:true,tier:"free",isPro:false,expiresAt:null,cachedAt:c,lastValidatedAt:c});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const t=await(0,import_headless.validateLicense)(e);if(n!==this.generation||readProjectKey(this.projectDir)!==e)return;const r=t.data?.relationships?.product?.data?.id;const s=t.meta.code==="EXPIRED"&&((0,import_headless.isRecognizedAccount)(t.data)||this.cache?.licenseKey===e);if(r&&r!==import_headless.KEYGEN_CONFIG.productId||!s&&!(0,import_headless.isAcceptedValidation)(t)){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const o=s?"free":(0,import_headless.resolveTier)(t.data);const i=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:t.data?.id??"",valid:true,tier:o,isPro:o==="pro",expiresAt:s?null:t.data.attributes.expiry,cachedAt:i,lastValidatedAt:i});this.persistCache();this.retry=0;this.nextCheck=i+import_headless.LICENSE_REVALIDATION_INTERVAL}catch(t){if(n!==this.generation)return;const r=(0,import_headless.isTransientLicenseError)(t)?import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]:void 0;this.nextCheck=Date.now()+(r??import_headless.LICENSE_REVALIDATION_INTERVAL)}finally{this.busy=false;this.notify()}}persistCache(){try{(0,import_node_fs.mkdirSync)((0,import_node_path.dirname)(this.cacheFile),{recursive:true,mode:448});(0,import_node_fs.writeFileSync)(`${this.cacheFile}.tmp`,JSON.stringify(this.cache),{mode:384});(0,import_node_fs.renameSync)(`${this.cacheFile}.tmp`,this.cacheFile)}catch{}}removeCache(){try{(0,import_node_fs.unlinkSync)(this.cacheFile)}catch{}}};var import_node_http=__toESM(require("http"));var import_node_crypto2=require("crypto");var import_socket=require("socket.io-client");var import_sync_broker2=require("@buoy-gg/sync-broker");var import_sync_broker=require("@buoy-gg/sync-broker");var BROKER_PORT=import_sync_broker.DEFAULT_BROKER_PORT;var BROKER_URL=`http://localhost:${BROKER_PORT}`;var DEFAULT_ACTION_TIMEOUT_MS=15e3;var UNRESPONSIVE_ACTION_TIMEOUT_MS=2500;var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS=1200;var RECOVERY_PROBE_MS=2e4;var UNRESPONSIVE_AFTER_MS=3e4;var DEFAULT_SNAPSHOT_TIMEOUT_MS=5e3;var DEVICE_RETENTION_MS=15*6e4;var DASHBOARD_NAME="Dashboard";var ALL_DEVICES="All";function isRecoveryAction(e,n){return e==="app"&&(n==="ping"||n==="reloadApp")}function isUnresponsive(e){return e>=2}function actionTimeoutMs(e){if(isRecoveryAction(e.toolId,e.action)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(!isUnresponsive(e.consecutiveTimeouts)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(e.now-e.lastFullBudgetAt>e.recoveryProbeMs){return{timeoutMs:e.fullMs,usedFullBudget:true}}return{timeoutMs:e.shortMs,usedFullBudget:false}}function diagnoseCrashed(e){if(e.consecutiveTimeouts>=2)return true;if(e.lastOkAt>0&&e.now-e.lastOkAt>e.unresponsiveAfterMs){return true}return false}var SyncClient=class{constructor(e={}){this.socket=null;this.admitted=false;this.lockedDevices=new Set;this.hostAccount=null;this.broker=null;this.capabilities=new Map;this.devices=[];this.snapshots=new Map;this.watching=new Map;this.pendingActions=new Map;this.snapshotWaiters=new Map;this.snapshotAt=new Map;this.health=new Map;this.devServerOrigins=new Map;this.originProbed=new Set;this.lastSeen=new Map;this.deviceConnections=new Map;this.port=e.port??BROKER_PORT;this.url=e.url??(e.port?`http://localhost:${e.port}`:BROKER_URL);this.actionTimeoutMs=e.actionTimeoutMs??DEFAULT_ACTION_TIMEOUT_MS;this.log=e.log??(n=>process.stderr.write(`[buoy-mcp] ${n}
|
|
66
|
-
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:a=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=snapKey(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===ALL_DEVICES){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return ALL_DEVICES}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=actionTimeoutMs({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=diagnoseCrashed({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(snapKey(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(snapKey(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=snapKey(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=snapKey(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(snapKey(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=snapKey(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>RECOVERY_PROBE_MS){u.lastFullBudgetAt=Date.now()}else{o=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=(0,import_node_crypto2.randomUUID)();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==ALL_DEVICES&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var snapKey=(e,n)=>`${e}::${n}`;function probeBroker(e,n=800){return new Promise(t=>{const r=import_node_http.default.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function waitForBroker(e,n=20){for(let t=0;t<n;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(n=>setTimeout(n,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod31=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.48"?"7.0.48":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function fetchLatest(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),FETCH_TIMEOUT_MS);try{const r=await fetch(REGISTRY_URL,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const n=await readCache();if(n&&e-n.checkedAt<CHECK_TTL_MS)return n.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const n=await resolveLatest(e);if(n&&isNewer(n,VERSION)){return`A newer buoy-mcp is available (${n}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const r=n.listDevices();if(r.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return json({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:i})}catch(o){return fail(errMsg(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return json({toolId:t,action:r,ok:true,result:c??null})}catch(a){return fail(errMsg(a))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,n){return typeof e==="number"&&e>0?e:n}function staleHeader(e){return e?`${e}
|
|
66
|
+
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:a=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=snapKey(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===ALL_DEVICES){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return ALL_DEVICES}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=actionTimeoutMs({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=diagnoseCrashed({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(snapKey(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(snapKey(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=snapKey(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=snapKey(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(snapKey(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=snapKey(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>RECOVERY_PROBE_MS){u.lastFullBudgetAt=Date.now()}else{o=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=(0,import_node_crypto2.randomUUID)();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==ALL_DEVICES&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var snapKey=(e,n)=>`${e}::${n}`;function probeBroker(e,n=800){return new Promise(t=>{const r=import_node_http.default.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function waitForBroker(e,n=20){for(let t=0;t<n;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(n=>setTimeout(n,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod31=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.49"?"7.0.49":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function fetchLatest(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),FETCH_TIMEOUT_MS);try{const r=await fetch(REGISTRY_URL,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const n=await readCache();if(n&&e-n.checkedAt<CHECK_TTL_MS)return n.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const n=await resolveLatest(e);if(n&&isNewer(n,VERSION)){return`A newer buoy-mcp is available (${n}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const r=n.listDevices();if(r.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return json({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:i})}catch(o){return fail(errMsg(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return json({toolId:t,action:r,ok:true,result:c??null})}catch(a){return fail(errMsg(a))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,n){return typeof e==="number"&&e>0?e:n}function staleHeader(e){return e?`${e}
|
|
67
67
|
|
|
68
68
|
`:""}function inactiveToolMessage(e){return`No ${e.toolId} result from the device. The ${e.toolId} tool may be inactive, or the app needs a newer Buoy (${e.mcpTool} requires the ${e.action} action).`}function withDevice(e,n){return async t=>{try{const r=e.requireProDevice(t.deviceId);return await n(t,r)}catch(r){return fail2(errMsg2(r))}}}var PERF_TOOL_ID="perf-monitor";var clamp=(e,n,t)=>Math.max(n,Math.min(t,e));var WARMUP_CASE_NAME="__batch_warmup__";var THERMAL_MARK={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var FALLBACK={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function pick(e,n,t){return e??n??t}function buildAutomationConfig(e,n={}){const t=[];const r=(e.targetRoute||"").trim();if(!r)throw new Error("targetRoute is required (the screen to benchmark).");let s=(e.bounceRoute||"").trim();if(!s)s=r==="/"?"/_buoy_bounce":"/";if(s===r){throw new Error(`bounceRoute ("${s}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`)}if(!Array.isArray(e.cases)||e.cases.length===0){throw new Error("At least one case is required.")}const o=e.cases.map((d,h)=>{const y=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:y,params:d.params??{},route:d.route}});const i=clamp(Math.round(pick(e.runsPerCase,n.runsPerCase,FALLBACK.runsPerCase)),1,10);const a=clamp(Math.round(pick(e.coolDownMs,n.coolDownMs,FALLBACK.coolDownMs)),0,3e4);const c=clamp(Math.round(pick(e.discardWarmupRuns,n.discardWarmupRuns,FALLBACK.discardWarmupRuns)),0,i-1);const u=e.shuffleCases??n.shuffleCases??o.length>3;if(o.length>3&&u===false){t.push("shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it.")}if(i===1){t.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.")}const l={targetRoute:r,bounceRoute:s,perCaseDurationMs:clamp(Math.round(pick(e.perCaseDurationMs,n.perCaseDurationMs,FALLBACK.perCaseDurationMs)),1e3,6e4),settleMs:clamp(Math.round(pick(e.settleMs,n.settleMs,FALLBACK.settleMs)),0,1e4),navTimeoutMs:clamp(Math.round(pick(e.navTimeoutMs,n.navTimeoutMs,FALLBACK.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,discardWarmupCase:e.discardWarmupCase??n.discardWarmupCase??true,shuffleCases:u,reloadBetweenCases:e.reloadBetweenCases??n.reloadBetweenCases??false,captureRenders:e.captureRenders??n.captureRenders??true};return{config:l,warnings:t}}function describeBenchmarkSettings(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=estimateBatchMs(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const i=[`**${n(e.perCaseDurationMs)} measured per page**`,t>1?`**${t} runs each** (median taken)`:`**1 run each** (no median \u2014 noisier)`,e.reloadBetweenCases?"**full reload between pages** (no state leaks across tests)":"no reload between pages",e.shuffleCases?"shuffled order (cancels thermal bias)":"fixed order",e.captureRenders!==false?"render capture on (per-component why-data)":"render capture off"];const a=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${i.join(", ")}. That's ~${n(a)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function estimateBatchMs(e){const n=1800;const t=e.settleMs+e.perCaseDurationMs+e.coolDownMs+n;let r=e.cases.length*(e.runsPerCase??1)*t;if(e.reloadBetweenCases)r+=e.cases.length*3e3;return r}function computeFlags(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.uiFps!=null&&t&&e.uiFps<t*.95){n.push("UI thread drops frames")}else if(e.uiJank!=null&&e.uiJank>=2){n.push("UI jank spikes")}if(e.cpu!=null&&e.cpu>=90)n.push("CPU near cap");if(e.jsJank!=null&&e.jsJank>=5)n.push("JS thread stalls");return n}function computeWebFlags(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Main thread drops frames")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}if(e.frameGapMaxMs!=null&&e.frameGapMaxMs>=100){n.push(`Freeze of ${Math.round(e.frameGapMaxMs)} ms`)}if(e.longTaskPct!=null&&e.longTaskPct>=20)n.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50){n.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`)}if(e.styleLayoutMs!=null&&e.styleLayoutMs>=100){n.push(`Style and layout in slow frames (${Math.round(e.styleLayoutMs)} ms)`)}if(e.worstInteractionMs!=null&&e.worstInteractionMs>=200){n.push(`Slow response to input (${Math.round(e.worstInteractionMs)} ms)`)}if(e.layoutShift!=null&&e.layoutShift>=.1)n.push(`Layout shifts (${e.layoutShift})`);if(e.domGrowth!=null&&e.domGrowth>0)n.push(`DOM grows each run (+${e.domGrowth} nodes)`);return n}function domGrowthAcrossRuns(e){const n=e.filter(r=>!r.batchFailureReason).sort((r,s)=>(r.runIndex??0)-(s.runIndex??0)).map(r=>r.webRun?.domNodesStart).filter(r=>typeof r==="number");if(n.length<3)return null;for(let r=1;r<n.length;r++)if(n[r]<=n[r-1])return null;const t=n[n.length-1]-n[0];return t>=Math.max(50,n[0]*.05)?t:null}function pickBaseline(e,n){const t=e.find(s=>s.isBaseline);if(t)return t;const r=n?.[0]?.name;if(r){const s=e.filter(o=>o.caseName===r);if(s.length===1)return s[0]}return e[0]}function rankBatchFromIndex(e,n,t,r=[]){const s=e.filter(p=>p.batchId===n);const o=new Map(r.map(p=>[p.index,p.reason]));const i=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,f)=>p-f);const a=p=>{const f=s.filter(w=>(w.batchIndex??0)===p);if(f.length===0)return void 0;return f.find(w=>w.isMedianRun===true)??f.find(w=>w.runIndex===0||w.runIndex===void 0)??f[0]};const c=p=>typeof p==="number"?p:null;const u=i.map(p=>{const f=a(p);const w=s.filter(I=>(I.batchIndex??0)===p);const b=w.length;const _=w.filter(I=>!I.batchFailureReason);const k=I=>I.webRun?I.jsFpsAvg:I.uiFpsAvg;const $=_.map(k).filter(I=>typeof I==="number");const A=f?.name??t?.[p]?.name??`case ${p+1}`;const R=s.some(I=>(I.batchIndex??0)===p&&I.isBaseline===true);const x=o.get(p)??f?.batchFailureReason;const S=Boolean(x)||!f;const T={batchIndex:p,caseName:A,params:f?.params??{},runs:b,failed:S,...R?{isBaseline:true}:{},failureReason:S?x??"no run recorded":void 0,uiFps:c(f?.uiFpsAvg),jsFps:c(f?.jsFpsAvg),cpu:c(f?.cpuAvg),memMb:c(f?.memMaxMb),jsJank:c(f?.jsJankFrames),uiJank:c(f?.uiJankFrames),deviceMaxRefreshRate:c(f?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:_.length,fpsMin:$.length?round(Math.min(...$),1):null,fpsMax:$.length?round(Math.max(...$),1):null,...f?.thermalState?{thermalState:f.thermalState}:{},renderCommits:c(f?.renderCommits),renderWasted:f?.renderWasted,topRenderers:f?.topRenderers??[]};const M=f?.webRun;if(!M)return{...T,flags:S?[]:computeFlags(T)};const O=[...M.longTasksSupported?[]:["long tasks"],...M.heapSupported?[]:["JS heap"],...M.loaf===null?["long animation frames"]:[]];const z={...T,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:M.heapSupported?T.memMb:null,deviceMaxRefreshRate:Math.max(T.deviceMaxRefreshRate??0,M.refreshHz??0)||null,longTaskPct:M.longTaskPct,frameGapP95Ms:M.frameGapP95Ms,frameGapMaxMs:M.frameGapMaxMs,longFrames:M.longFrames,...O.length?{missingMetrics:O}:{},loafBlockingMs:M.loaf?.blockingMs??null,styleLayoutMs:M.loaf?.styleLayoutMs??null,forcedLayoutMs:M.loaf?.forcedLayoutMs??null,topScripts:M.loaf?.topScripts??[],worstInteractionMs:M.interactions?.worstMs??null,layoutShift:M.layoutShift??null,domNodes:M.domNodesEnd??null,domGrowth:domGrowthAcrossRuns(w),pageLoad:M.pageLoad??null};return{...z,flags:S?[]:computeWebFlags({...z,frames:M.frames})}}).filter(p=>p.caseName!==WARMUP_CASE_NAME).map((p,f)=>({...p,batchIndex:f}));const l=pickBaseline(u,t);if(l){l.isBaseline=true;for(const p of u){if(p===l)continue;if(p.uiFps!=null&&l.uiFps!=null)p.deltaUiFps=round(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=round(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,f)=>{let w;let b=NaN;for(const _ of d){const k=p(_);if(k==null)continue;if(w===void 0||f(k,b)){w=_;b=k}}return w?.caseName};const y=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const v={uiFps:h(p=>p.uiFps,(p,f)=>p>f),jsFps:h(p=>p.jsFps,(p,f)=>p>f),lowestCpu:h(p=>p.cpu,(p,f)=>p<f),lowestMem:h(p=>p.memMb,(p,f)=>p<f),fewestJank:h(y,(p,f)=>p<f),fewestLongTasks:h(p=>p.longTaskPct??null,(p,f)=>p<f)};const m=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const g=d.filter(p=>p.flags.length===0).sort((p,f)=>(f.uiFps??f.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(f.cpu??f.longTaskPct??Infinity)||(p.memMb??Infinity)-(f.memMb??Infinity)).map(p=>p.caseName);return{batchId:n,baselineName:l?.caseName??null,deviceMaxRefreshRate:d.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:v,flagged:m,balanced:g}}var FRESH_LOAD_MS=2e4;function pageLoadLines(e){const n=e.filter(r=>!r.failed);if(n.length===0||!n.every(r=>r.pageLoad&&r.pageLoad.pageAgeMs<FRESH_LOAD_MS)){return[]}const t=r=>r==null?"\u2014":String(Math.round(r));return[`**Page load** (each case started on a fresh page load):`,"",`| Case | TTFB ms | FCP ms | LCP ms | DOM ready ms | Script KB | Total KB |`,`|------|--------:|-------:|-------:|-------------:|----------:|---------:|`,...n.map(r=>{const s=r.pageLoad;return`| ${r.caseName} | ${t(s.ttfbMs)} | ${t(s.fcpMs)} | ${t(s.lcpMs)} | ${t(s.domContentLoadedMs)} | ${t(s.scriptKb)} | ${t(s.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function closeCallNote(e){const n=o=>o.platform==="web"?o.jsFps:o.uiFps;const t=e.filter(o=>!o.failed&&n(o)!=null&&o.fpsMin!=null&&o.fpsMax!=null).sort((o,i)=>(n(i)??0)-(n(o)??0));if(t.length<2)return null;const[r,s]=t;if((r.validRuns??0)<2||(s.validRuns??0)<2){return`**Too few runs to separate the top cases.** ${r.caseName} and ${s.caseName} each need 2+ valid runs before their FPS gap means anything.`}if((s.fpsMax??0)>=(r.fpsMin??0)){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap across runs. Run more repeats before picking between them.`}return null}function formatRankingMarkdown(e){const n=(h,y=1)=>h==null?"\u2014":h.toFixed(y);const t=h=>h==null?"":h>0?` (+${h.toFixed(1)})`:` (${h.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(h=>h.platform==="web")){formatWebTable(e,r);return r.join("\n")}r.push(`**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`);r.push("");const s=e.rows.some(h=>h.thermalState);const o=s?" Thermal |":"";const i=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${i}-------|`);for(const h of e.rows){const y=s?` ${THERMAL_MARK[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${y} \u274C FAILED (${h.failureReason}) |`);continue}const v=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"\u2713 clean";r.push(`| ${h.batchIndex} | ${h.caseName} | ${n(h.uiFps)}${t(h.deltaUiFps)} | ${n(h.jsFps)}${t(h.deltaJsFps)} | ${n(h.cpu)} | ${n(h.memMb)} | ${n(h.jsJank,0)} | ${n(h.uiJank,0)} |${y} ${v} |`)}const a=closeCallNote(e.rows);if(a){r.push("");r.push(a)}const c=e.rows.filter(h=>h.thermalState==="serious"||h.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(h=>h.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`)}r.push("");const u=e.leaders;const l=[u.uiFps&&`UI FPS \u2192 **${u.uiFps}**`,u.jsFps&&`JS FPS \u2192 **${u.jsFps}**`,u.lowestCpu&&`lowest CPU \u2192 **${u.lowestCpu}**`,u.lowestMem&&`lowest mem \u2192 **${u.lowestMem}**`,u.fewestJank&&`fewest jank \u2192 **${u.fewestJank}**`].filter(Boolean).join(" \xB7 ");if(l)r.push(`**Per-metric best:** ${l}`);r.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);if(e.flagged.length){r.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(h=>`${h.caseName} (${h.flags.join("; ")})`).join(" \xB7 ")}`)}const d=e.rows.filter(h=>!h.failed&&(h.renderCommits!=null||h.topRenderers.length>0)).map(h=>{const y=h.topRenderers.map(g=>`${g.name} \xD7${g.renders} (${g.totalMs}ms${g.cause?`, ${g.cause}`:""})`).join(", ");const v=h.renderCommits!=null?`${h.renderCommits} commits`:"";const m=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[v,m,y].filter(Boolean).join(": ")}`});if(d.length){r.push("");r.push("**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream.");r.push(...d)}r.push("");r.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`);return r.join("\n")}function compareReportStats(e,n){const t=(r,s)=>({a:{avg:round(r.avg,1),p95:round(r.p95,1)},b:{avg:round(s.avg,1),p95:round(s.p95,1)},deltaAvg:round(s.avg-r.avg,1)});return{a:e.name,b:n.name,uiFps:t(e.stats.uiFps,n.stats.uiFps),jsFps:t(e.stats.jsFps,n.stats.jsFps),cpuUsage:t(e.stats.cpuUsage,n.stats.cpuUsage),memoryUsage:t(e.stats.memoryUsage,n.stats.memoryUsage),jsJankFrames:{a:e.stats.jsJankFrames,b:n.stats.jsJankFrames,delta:n.stats.jsJankFrames-e.stats.jsJankFrames},uiJankFrames:{a:e.stats.uiJankFrames,b:n.stats.uiJankFrames,delta:n.stats.uiJankFrames-e.stats.uiJankFrames},...compareRenders(e.renders,n.renders)}}function compareRenders(e,n){if(!e&&!n)return null;const t=r=>r??0;return{renders:{total:{a:t(e?.totalRenders),b:t(n?.totalRenders),delta:t(n?.totalRenders)-t(e?.totalRenders)},commits:{a:t(e?.totalCommits),b:t(n?.totalCommits),delta:t(n?.totalCommits)-t(e?.totalCommits)},wasted:{a:t(e?.wastedRenders),b:t(n?.wastedRenders),delta:t(n?.wastedRenders)-t(e?.wastedRenders)},causes:{a:e?.causes??{},b:n?.causes??{}}}}}function round(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function formatWebTable(e,n){const t=(l,d=1)=>l==null?"\u2014":l.toFixed(d);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**No single winner \u2014 weigh the trade-offs.** This is a browser run. FPS is the main thread's frame rate (requestAnimationFrame); it can't see frames dropped later by the GPU or compositor. Long tasks % is time the main thread was blocked by tasks over 50 ms, not CPU use.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | Heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const d=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${d} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const h=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"\u2713 clean";n.push(`| ${l.batchIndex} | ${d} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${h} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const d=[l.loafBlockingMs!=null&&l.loafBlockingMs>0&&`slow frames blocked input ${l.loafBlockingMs} ms`,l.styleLayoutMs!=null&&l.styleLayoutMs>0&&`style + layout ${l.styleLayoutMs} ms`,l.forcedLayoutMs!=null&&l.forcedLayoutMs>0&&`forced layout ${l.forcedLayoutMs} ms`,l.worstInteractionMs!=null&&`slowest input ${l.worstInteractionMs} ms`,l.layoutShift!=null&&l.layoutShift>0&&`layout shift ${l.layoutShift}`,l.domNodes!=null&&`${l.domNodes} DOM nodes${l.domGrowth?` (+${l.domGrowth} over the runs)`:""}`,l.topScripts&&l.topScripts.length>0&&`top scripts: ${l.topScripts.slice(0,3).map(h=>`${h.name} ${h.ms} ms`).join(", ")}`].filter(Boolean);return d.length?`- **${l.caseName}**: ${d.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size):`);n.push(...s)}const o=pageLoadLines(e.rows);if(o.length){n.push("");n.push(...o)}const i=closeCallNote(e.rows);if(i){n.push("");n.push(i)}const a=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(a.length){n.push("");n.push(`**Not measured in this browser:** ${a.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.deviceMaxRefreshRate){n.push("");n.push(`Screen rate: ${e.deviceMaxRefreshRate} Hz. A frame gap over ${(1500/e.deviceMaxRefreshRate).toFixed(1)} ms missed at least one frame.`)}n.push("");const c=e.leaders;const u=[c.jsFps&&`FPS \u2192 **${c.jsFps}**`,c.fewestJank&&`fewest long frames \u2192 **${c.fewestJank}**`,c.fewestLongTasks&&`fewest long tasks \u2192 **${c.fewestLongTasks}**`,c.lowestMem&&`lowest heap \u2192 **${c.lowestMem}**`].filter(Boolean).join(" \xB7 ");if(u)n.push(`**Per-metric leaders:** ${u}`);if(e.flagged.length){n.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(l=>`${l.caseName} (${l.flags.join("; ")})`).join(" \xB7 ")}`)}n.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);n.push("");n.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Confirm finalists on a real phone browser: desktop numbers don't predict mobile GPU, memory or heat._`)}var POLL_INTERVAL_MS=1500;var delay2=e=>new Promise(n=>setTimeout(n,e));var json2=e=>({content:[{type:"text",text:JSON.stringify(e,null,2)}]});var caseSchema=import_zod3.z.object({name:import_zod3.z.string().describe("Display name for this variant (becomes the saved run's name)."),params:import_zod3.z.record(import_zod3.z.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),route:import_zod3.z.string().optional().describe("Optional per-case route override; defaults to targetRoute.")});async function readDeviceSettings(e,n){try{const t=await e.callAction(PERF_TOOL_ID,"getAutomationConfig",{},n);if(!t)return{};const{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}}catch{return{}}}async function readPerfSnapshot(e,n){const{payload:t}=await e.getSnapshot(PERF_TOOL_ID,n,{timeoutMs:4e3});return t}function batchIdsIn(e){const n=new Set;for(const t of e??[])if(t.batchId)n.add(t.batchId);return n}function runCountFor(e,n){return(e??[]).filter(t=>t.batchId===n).length}function newestBatchId(e){let n=null;for(const t of e??[]){if(t.batchId&&(!n||t.createdAt>n.at))n={id:t.batchId,at:t.createdAt}}return n?.id??null}function failuresFromIndex(e,n){const t=new Map;for(const r of e??[]){if(r.batchId===n&&r.batchFailureReason&&!t.has(r.batchIndex??0)){t.set(r.batchIndex??0,r.batchFailureReason)}}return[...t.entries()].map(([r,s])=>({index:r,reason:s}))}function expectedRunsFor(e){const n=e.cases.length+(e.discardWarmupCase===true?1:0);return n*(e.runsPerCase??1)}var SETTLE_WAIT_MS=1e4;function isRunnerBusy(e){return!!e&&e!=="idle"&&e!=="done"&&e!=="cancelled"}async function waitForRunnerSettled(e,n,t,r=SETTLE_WAIT_MS){const s=Date.now();while(Date.now()-s<r){const o=await readPerfSnapshot(e,n).catch(()=>null);if(o&&!isRunnerBusy(o.live?.automation?.phase))return true;await delay2(POLL_INTERVAL_MS)}t(`automation still busy after ${Math.round(r/1e3)}s`);return false}async function waitForBatchByIndex(e,n,t){const{preBatchIds:r,expectedRuns:s,maxWaitMs:o,log:i}=t;const a=Date.now();let c="";let u=false;let l=0;while(Date.now()-a<o){let y;try{y=await readPerfSnapshot(e,n)}catch{await delay2(POLL_INTERVAL_MS);continue}const v=y.index??[];const m=y.live?.automation?.phase??"idle";const g=y.live?.automationCompleted;if(g?.phase==="cancelled"&&!r.has(g.batchId)){return{batchId:g.batchId,failures:[],cancelled:true}}if(g?.phase==="done"&&!r.has(g.batchId)){return{batchId:g.batchId,failures:g.failures??failuresFromIndex(v,g.batchId)}}if(m!=="idle")u=true;if(m!==c){c=m;i(`automation phase: ${m}`)}const p=[...batchIdsIn(v)].find(f=>!r.has(f));if(p){const f=runCountFor(v,p);if(s>0&&f>=s){await waitForRunnerSettled(e,n,i);i(`batch ${p} complete (${f}/${s} runs saved)`);return{batchId:p,failures:failuresFromIndex(v,p)}}if(m==="idle"&&u&&f>0){if(++l>=3){i(`batch ${p} settled (${f} runs, automation idle)`);return{batchId:p,failures:failuresFromIndex(v,p)}}}else{l=0}}await delay2(POLL_INTERVAL_MS)}const d=await readPerfSnapshot(e,n).catch(()=>null);const h=d?[...batchIdsIn(d.index)].find(y=>!r.has(y)):void 0;if(h&&d)return{batchId:h,failures:failuresFromIndex(d.index,h),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${c||"idle"}). Check get_automation_status \u2014 the device may not have navigated.`)}function registerPerfMonitorTools(e,n){e.registerTool("run_benchmark_batch",{description:"Run a perf-monitor benchmark batch and return per-metric leaders, risk flags and a comparison of available case results. Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",inputSchema:{targetRoute:import_zod3.z.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),cases:import_zod3.z.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),bounceRoute:import_zod3.z.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),runsPerCase:import_zod3.z.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),perCaseDurationMs:import_zod3.z.number().optional().describe("How long to record each run. Default 5000."),settleMs:import_zod3.z.number().optional().describe("Idle gap after navigation before recording. Default 600."),coolDownMs:import_zod3.z.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),navTimeoutMs:import_zod3.z.number().optional().describe("Max wait for navigation per run. Default 5000."),discardWarmupRuns:import_zod3.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),shuffleCases:import_zod3.z.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),reloadBetweenCases:import_zod3.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),captureRenders:import_zod3.z.boolean().optional().describe("Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds instrumentation overhead that can vary with the workload. Default true."),deviceId:import_zod3.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),maxWaitMs:import_zod3.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")}},async t=>{const r=s=>process.stderr.write(`[buoy-mcp] ${s}
|
|
69
69
|
`);try{const s=n.requireProDevice(t.deviceId);const o=await readDeviceSettings(n,s);const{config:i,warnings:a}=buildAutomationConfig(t,o);const c=estimateBatchMs(i);const u=t.maxWaitMs??c*3+6e4;const l=expectedRunsFor(i);if(!await waitForRunnerSettled(n,s,r)){return fail2("Another benchmark batch is still running on this device. Wait for it to finish, or cancel it (call_action perf-monitor cancelAutomation), then retry.")}await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:true},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const d=await readPerfSnapshot(n,s);const h=batchIdsIn(d.index);r(`starting batch: ${i.cases.length} cases \xD7 ${i.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(PERF_TOOL_ID,"startAutomation",{config:i},s);const y=await waitForBatchByIndex(n,s,{preBatchIds:h,expectedRuns:l,maxWaitMs:u,log:r});if(y.cancelled){await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});return fail2(`Batch ${y.batchId} was cancelled before completing.`)}await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const v=await readPerfSnapshot(n,s);const m=rankBatchFromIndex(v.index??[],y.batchId,i.cases,y.failures);await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:false},s).catch(()=>{});if(m.rows.length===0){const f=n.connectedDevices().some(b=>b.deviceId===s);const w=!f?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:y.timedOut?`No runs were saved before the wait cap (${Math.round(u/1e3)}s). On web, a hidden or background tab pauses the run: keep the tab in front. Check get_automation_status for the device's phase.`:`The batch finished without saving any runs. Check get_automation_status and the device console.`;return fail2(`Benchmark batch ${y.batchId||"(no id)"} saved no runs. ${w}`)}const g=formatRankingMarkdown(m);const p={batchId:y.batchId,leaders:m.leaders,balanced:m.balanced,flagged:m.flagged,rows:m.rows,failures:y.failures,warnings:a,estimateMs:c,...y.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return text(`${g}
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{SyncClient as a,createMcpServer as p,runInit as l}from"./chunk-
|
|
2
|
+
import{SyncClient as a,createMcpServer as p,runInit as l}from"./chunk-MLEZPZP3.mjs";import"./chunk-2PTJT2QF.mjs";import{StdioServerTransport as d}from"@modelcontextprotocol/sdk/server/stdio.js";console.log=(...e)=>console.error(...e);console.info=(...e)=>console.error(...e);console.debug=(...e)=>console.error(...e);async function u(){const e=process.env.BUOY_BROKER_PORT;const s=new a({url:process.env.BUOY_BROKER_URL,port:e?Number(e):void 0,autoSpawnBroker:process.env.BUOY_NO_SPAWN!=="1"});const t=p(s);const c=new d;let n=false;const r=o=>{if(n)return;n=true;process.stderr.write(`[buoy-mcp] shutting down (${o})
|
|
3
3
|
`);setTimeout(()=>process.exit(0),2e3).unref();void(async()=>{await s.stop().catch(()=>{});await t.close().catch(()=>{});process.exit(0)})()};process.stdin.once("end",()=>r("stdin closed"));process.stdin.once("close",()=>r("stdin closed"));t.server.onclose=()=>r("transport closed");process.on("SIGINT",()=>r("SIGINT"));process.on("SIGTERM",()=>r("SIGTERM"));process.on("SIGHUP",()=>r("SIGHUP"));await t.connect(c);process.stderr.write(`[buoy-mcp] serving MCP over stdio; connecting to broker\u2026
|
|
4
4
|
`);s.start().then(o=>process.stderr.write(`[buoy-mcp] broker ${o}
|
|
5
5
|
`)).catch(o=>process.stderr.write(`[buoy-mcp] broker connection failed: ${o?.message??o} \u2014 tools will report no devices until it recovers
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use strict";var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(e,n)=>{for(var t in n)__defProp(e,t,{get:n[t],enumerable:true})};var __copyProps=(e,n,t,r)=>{if(n&&typeof n==="object"||typeof n==="function"){for(let s of __getOwnPropNames(n))if(!__hasOwnProp.call(e,s)&&s!==t)__defProp(e,s,{get:()=>n[s],enumerable:!(r=__getOwnPropDesc(n,s))||r.enumerable})}return e};var __toESM=(e,n,t)=>(t=e!=null?__create(__getProtoOf(e)):{},__copyProps(n||!e||!e.__esModule?__defProp(t,"default",{value:e,enumerable:true}):t,e));var __toCommonJS=e=>__copyProps(__defProp({},"__esModule",{value:true}),e);var src_exports={};__export(src_exports,{ALL_DEVICES:()=>ALL_DEVICES,BROKER_PORT:()=>BROKER_PORT,BROKER_URL:()=>BROKER_URL,DASHBOARD_NAME:()=>DASHBOARD_NAME,DEFAULT_ACTION_TIMEOUT_MS:()=>DEFAULT_ACTION_TIMEOUT_MS,DEFAULT_SNAPSHOT_TIMEOUT_MS:()=>DEFAULT_SNAPSHOT_TIMEOUT_MS,PACKAGE_NAME:()=>PACKAGE_NAME,SyncClient:()=>SyncClient,VERSION:()=>VERSION,buildAutomationConfig:()=>buildAutomationConfig,compareReportStats:()=>compareReportStats,createMcpServer:()=>createMcpServer,estimateBatchMs:()=>estimateBatchMs,formatRankingMarkdown:()=>formatRankingMarkdown,getUpdateNotice:()=>getUpdateNotice,rankBatchFromIndex:()=>rankBatchFromIndex,registerConsoleTools:()=>registerConsoleTools,registerEventsTools:()=>registerEventsTools,registerGenericTools:()=>registerGenericTools,registerJotaiTools:()=>registerJotaiTools,registerPerfMonitorTools:()=>registerPerfMonitorTools,registerReactQueryTools:()=>registerReactQueryTools,registerReduxTools:()=>registerReduxTools,registerRoutesTools:()=>registerRoutesTools,registerScreenshotTools:()=>registerScreenshotTools,registerStorageTools:()=>registerStorageTools,registerZustandTools:()=>registerZustandTools,runInit:()=>runInit});module.exports=__toCommonJS(src_exports);var import_node_fs=require("fs");var import_node_path=require("path");var import_node_os=require("os");var import_node_crypto=require("crypto");var import_headless=require("@buoy-gg/license/headless");var KEY_NAMES=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function readProjectKey(e,n=process.env){for(const t of KEY_NAMES)if(n[t]!==void 0)return(0,import_headless.normalizeLicenseKey)(n[t])||null;for(const t of[".env.development.local",".env.local",".env"]){let r;try{r=(0,import_node_fs.readFileSync)((0,import_node_path.join)(e,t),"utf8")}catch{continue}const s=new Map;for(const o of r.split(/\r?\n/)){const i=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!i)continue;const a=i[2].trim();const c=a.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(i[1],c?c[2]:a.split("#")[0].trim())}for(const o of KEY_NAMES)if(s.has(o))return(0,import_headless.normalizeLicenseKey)(s.get(o))||null}return null}var NodeAccount=class{constructor(e,n={}){this.changed=e;this.key=null;this.cache=null;this.timer=null;this.unsubscribeSession=null;this.generation=0;this.busy=false;this.nextCheck=0;this.retry=0;this.lastAdmission=false;this.hasAccess=()=>this.hasKeyAccess()||this.session?.getStatus().state==="active";this.lastSessionSync=0;this.projectDir=(0,import_node_path.resolve)(n.projectDir??process.env.BUOY_PROJECT_DIR??process.cwd());const t=(0,import_node_crypto.createHash)("sha256").update(this.projectDir).digest("hex").slice(0,20);this.cacheFile=n.cacheFile??(0,import_node_path.join)((0,import_node_os.homedir)(),".buoy",`account-${t}.json`);this.session=n.session!==void 0?n.session:(0,import_headless.createNodeSession)({audience:import_headless.CLI_AUDIENCE,openBrowser:()=>{throw new Error("The MCP does not open a browser. Run `npx buoy login`.")}})}hasKeyAccess(){return(0,import_headless.resolveAccountAccess)({initialized:true,validating:false,key:this.key,tierSource:this.cache?.tier?"key":"none",verifiedAt:this.cache?.cachedAt??null}).status==="admitted"}start(){if(this.timer)return;if(this.session){this.unsubscribeSession=this.session.subscribe(()=>this.notify());void this.session.load().then(()=>this.notify(),()=>{})}try{const e=JSON.parse((0,import_node_fs.readFileSync)(this.cacheFile,"utf8"));if(e.valid&&(0,import_headless.verifyCacheSignature)(e))this.cache=e}catch{}void this.tick();this.timer=setInterval(()=>{void this.tick()},1e3);this.timer.unref()}stop(){++this.generation;this.unsubscribeSession?.();this.unsubscribeSession=null;if(this.timer)clearInterval(this.timer);this.timer=null;this.key=null;this.cache=null;this.notify()}notify(){const e=this.hasAccess();if(e===this.lastAdmission)return;this.lastAdmission=e;this.changed()}async tick(){if(this.session&&Date.now()-this.lastSessionSync>15e3){this.lastSessionSync=Date.now();void this.session.sync().catch(()=>{})}const e=readProjectKey(this.projectDir);if(e!==this.key){++this.generation;this.key=e;if(this.cache?.licenseKey!==e)this.cache=null;this.nextCheck=0;this.retry=0;if(!e)this.removeCache()}this.notify();if(!e||this.busy||Date.now()<this.nextCheck)return;if(this.cache&&this.hasKeyAccess()&&Date.now()-this.cache.lastValidatedAt<import_headless.LICENSE_LAUNCH_VALIDATION_TTL&&this.nextCheck===0){this.nextCheck=this.cache.lastValidatedAt+import_headless.LICENSE_LAUNCH_VALIDATION_TTL;return}this.busy=true;const n=this.generation;try{if((0,import_headless.isDevToken)(e)){const a=(0,import_headless.checkDevToken)(e);if(!a.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),a.claims.iat*1e3);const u=a.claims.tier==="free"?"free":"pro";this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`dev:${a.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(a.claims.exp*1e3).toISOString(),cachedAt:c,lastValidatedAt:Date.now()});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}if((0,import_headless.isServiceKey)(e)){const a=await(0,import_headless.exchangeServiceKey)(e,{origin:process.env.BUOY_SITE_URL||"https://buoy.gg"});if(n!==this.generation||readProjectKey(this.projectDir)!==e)return;if(!a.ok){if(a.reason==="rejected"){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL}else{this.nextCheck=Date.now()+(import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]??import_headless.LICENSE_REVALIDATION_INTERVAL)}return}const c=Date.now();const u=a.claims.exp*1e3;this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`service:${a.claims.sid}`,valid:true,tier:"pro",isPro:true,expiresAt:new Date(u).toISOString(),cachedAt:c,lastValidatedAt:c});this.persistCache();this.retry=0;this.nextCheck=Math.max(c+6e4,u-60*60*1e3);return}if((0,import_headless.isBuoyToken)(e)){const a=(0,import_headless.verifyFreeToken)(e);if(!a){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`token:${a.accountHash}`,valid:true,tier:"free",isPro:false,expiresAt:null,cachedAt:c,lastValidatedAt:c});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const t=await(0,import_headless.validateLicense)(e);if(n!==this.generation||readProjectKey(this.projectDir)!==e)return;const r=t.data?.relationships?.product?.data?.id;const s=t.meta.code==="EXPIRED"&&((0,import_headless.isRecognizedAccount)(t.data)||this.cache?.licenseKey===e);if(r&&r!==import_headless.KEYGEN_CONFIG.productId||!s&&!(0,import_headless.isAcceptedValidation)(t)){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const o=s?"free":(0,import_headless.resolveTier)(t.data);const i=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:t.data?.id??"",valid:true,tier:o,isPro:o==="pro",expiresAt:s?null:t.data.attributes.expiry,cachedAt:i,lastValidatedAt:i});this.persistCache();this.retry=0;this.nextCheck=i+import_headless.LICENSE_REVALIDATION_INTERVAL}catch(t){if(n!==this.generation)return;const r=(0,import_headless.isTransientLicenseError)(t)?import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]:void 0;this.nextCheck=Date.now()+(r??import_headless.LICENSE_REVALIDATION_INTERVAL)}finally{this.busy=false;this.notify()}}persistCache(){try{(0,import_node_fs.mkdirSync)((0,import_node_path.dirname)(this.cacheFile),{recursive:true,mode:448});(0,import_node_fs.writeFileSync)(`${this.cacheFile}.tmp`,JSON.stringify(this.cache),{mode:384});(0,import_node_fs.renameSync)(`${this.cacheFile}.tmp`,this.cacheFile)}catch{}}removeCache(){try{(0,import_node_fs.unlinkSync)(this.cacheFile)}catch{}}};var import_node_http=__toESM(require("http"));var import_node_crypto2=require("crypto");var import_socket=require("socket.io-client");var import_sync_broker2=require("@buoy-gg/sync-broker");var import_sync_broker=require("@buoy-gg/sync-broker");var BROKER_PORT=import_sync_broker.DEFAULT_BROKER_PORT;var BROKER_URL=`http://localhost:${BROKER_PORT}`;var DEFAULT_ACTION_TIMEOUT_MS=15e3;var UNRESPONSIVE_ACTION_TIMEOUT_MS=2500;var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS=1200;var RECOVERY_PROBE_MS=2e4;var UNRESPONSIVE_AFTER_MS=3e4;var DEFAULT_SNAPSHOT_TIMEOUT_MS=5e3;var DEVICE_RETENTION_MS=15*6e4;var DASHBOARD_NAME="Dashboard";var ALL_DEVICES="All";function isRecoveryAction(e,n){return e==="app"&&(n==="ping"||n==="reloadApp")}function isUnresponsive(e){return e>=2}function actionTimeoutMs(e){if(isRecoveryAction(e.toolId,e.action)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(!isUnresponsive(e.consecutiveTimeouts)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(e.now-e.lastFullBudgetAt>e.recoveryProbeMs){return{timeoutMs:e.fullMs,usedFullBudget:true}}return{timeoutMs:e.shortMs,usedFullBudget:false}}function diagnoseCrashed(e){if(e.consecutiveTimeouts>=2)return true;if(e.lastOkAt>0&&e.now-e.lastOkAt>e.unresponsiveAfterMs){return true}return false}var SyncClient=class{constructor(e={}){this.socket=null;this.admitted=false;this.lockedDevices=new Set;this.hostAccount=null;this.broker=null;this.capabilities=new Map;this.devices=[];this.snapshots=new Map;this.watching=new Map;this.pendingActions=new Map;this.snapshotWaiters=new Map;this.snapshotAt=new Map;this.health=new Map;this.devServerOrigins=new Map;this.originProbed=new Set;this.lastSeen=new Map;this.deviceConnections=new Map;this.port=e.port??BROKER_PORT;this.url=e.url??(e.port?`http://localhost:${e.port}`:BROKER_URL);this.actionTimeoutMs=e.actionTimeoutMs??DEFAULT_ACTION_TIMEOUT_MS;this.log=e.log??(n=>process.stderr.write(`[buoy-mcp] ${n}
|
|
2
|
-
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:a=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=snapKey(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===ALL_DEVICES){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return ALL_DEVICES}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=actionTimeoutMs({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=diagnoseCrashed({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(snapKey(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(snapKey(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=snapKey(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=snapKey(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(snapKey(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=snapKey(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>RECOVERY_PROBE_MS){u.lastFullBudgetAt=Date.now()}else{o=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=(0,import_node_crypto2.randomUUID)();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==ALL_DEVICES&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var snapKey=(e,n)=>`${e}::${n}`;function probeBroker(e,n=800){return new Promise(t=>{const r=import_node_http.default.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function waitForBroker(e,n=20){for(let t=0;t<n;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(n=>setTimeout(n,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod31=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.48"?"7.0.48":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function fetchLatest(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),FETCH_TIMEOUT_MS);try{const r=await fetch(REGISTRY_URL,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const n=await readCache();if(n&&e-n.checkedAt<CHECK_TTL_MS)return n.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const n=await resolveLatest(e);if(n&&isNewer(n,VERSION)){return`A newer buoy-mcp is available (${n}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const r=n.listDevices();if(r.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return json({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:i})}catch(o){return fail(errMsg(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return json({toolId:t,action:r,ok:true,result:c??null})}catch(a){return fail(errMsg(a))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,n){return typeof e==="number"&&e>0?e:n}function staleHeader(e){return e?`${e}
|
|
2
|
+
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let n="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}n="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return n}connectSocket(){return new Promise((e,n)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:a=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));a(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let r=false;const s=()=>{if(r)return;r=true;this.log("connected to broker");e()};const o=setTimeout(()=>{if(r)return;r=true;n(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",a=>{const c=!!a&&typeof a==="object"&&a.protocol===1&&a.ready===true;this.setAdmission(c);if(c){clearTimeout(o);this.rearmWatches();s()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const i=a=>a.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",a=>{if(r)return;r=true;clearTimeout(o);n(new Error(i(a)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${a.message}`))});t.on("connect_error",a=>{if(!i(a)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;this.capabilities.set(a.persistentDeviceId,a);void this.learnDevServerOrigin(a.persistentDeviceId)});t.on("all-devices-update",a=>{if(!this.admitted)return;if(!Array.isArray(a))return;let c=false;for(const l of a){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=a;const u=Date.now();for(const l of a)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(a)});t.on("devtool-sync",a=>{if(!this.admitted||this.lockedDevices.has(a.persistentDeviceId))return;const c=snapKey(a.persistentDeviceId,a.toolId);this.snapshots.set(c,a.payload);this.snapshotAt.set(c,Date.now());const u=this.snapshotWaiters.get(c);if(u&&u.length){this.snapshotWaiters.set(c,[]);for(const l of u)l(a.payload)}});t.on("devtool-action-result",a=>{if(!this.admitted)return;const c=this.pendingActions.get(a.requestId);if(!c)return;this.pendingActions.delete(a.requestId);clearTimeout(c.timer);c.resolve(a)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,n=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!n)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const r of t)r(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const n=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(n)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,r]of this.snapshotWaiters)if(t.startsWith(n)){this.snapshotWaiters.delete(t);for(const s of r)s(void 0)}for(const[t,r]of this.pendingActions)if(r.target===e||r.target===ALL_DEVICES){clearTimeout(r.timer);this.pendingActions.delete(t);r.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[n,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(s=>s.deviceId===n))continue;this.lastSeen.delete(n);this.capabilities.delete(n);this.health.delete(n);this.devServerOrigins.delete(n);this.originProbed.delete(n);const r=`${n}::`;for(const s of[...this.snapshots.keys()]){if(s.startsWith(r)){this.snapshots.delete(s);this.snapshotAt.delete(s)}}for(const s of[...this.watching.keys()]){if(s.startsWith(r))this.watching.delete(s)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const n of e)n(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const n=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:n?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(n=>{const t=n.tools.find(r=>r.toolId===e);return t?{deviceId:n.deviceId,deviceName:n.deviceName,actions:t.actions}:null}).filter(n=>n!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const n=this.connectedDevices();const t=n.filter(o=>!this.lockedDevices.has(o.deviceId));if(t.length===0){if(n.length>0){throw new Error(`Devices are connected (${n.map(o=>o.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const r=t.filter(o=>o.tools.length>0);if(r.length===1)return r[0].deviceId;if(r.length===0){const o=t.map(i=>i.deviceName).join(", ");throw new Error(`Connected devices (${o}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const s=r.map(o=>`${o.deviceName} (${o.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${s}`)}requireProDevice(e){this.requireAdmission();const n=o=>{if(!o)return"";return o.tier==="locked"?` The broker has not confirmed account access for "${o.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${o.deviceName}" is on the ${o.tier} tier.`};const t=o=>`Buoy Pro is required to use the MCP.${n(o)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return ALL_DEVICES}const r=this.resolveDevice(e);const s=this.connectedDevices().find(o=>o.deviceId===r);if(!s?.isPro)throw new Error(t(s));return r}healthOf(e){let n=this.health.get(e);if(!n){n={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,n)}return n}noteOk(e){const n=this.healthOf(e);n.consecutiveTimeouts=0;n.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=actionTimeoutMs({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:n,action:t});if(o)r.lastFullBudgetAt=Date.now();return s}unresponsiveNote(e){const n=this.healthOf(e);const t=n.lastOkAt?Math.round((Date.now()-n.lastOkAt)/1e3):null;const r=diagnoseCrashed({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const s=[`The app on "${e}" is not responding (${n.consecutiveTimeouts} call${n.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,r?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const o=this.lastFatalFor(e);if(o)s.push(`Last captured error: ${o}`);s.push(o?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return s.join(" ")}lastFatalFor(e){const n=this.snapshots.get(snapKey(e,"console"));const t=n?.entries;if(!Array.isArray(t))return null;for(let r=t.length-1;r>=0;r--){const s=t[r];if(s?.fatal||s?.level==="error"){const o=(s.message??"").split("\n")[0];if(o)return o.length>300?`${o.slice(0,300)}\u2026`:o}}return null}snapshotAgeMs(e,n){this.requireAdmission();const t=this.snapshotAt.get(snapKey(n,e));return t?Date.now()-t:null}noteDevServerOrigin(e,n){if(typeof n==="string"&&/^https?:\/\//.test(n)){this.devServerOrigins.set(e,n)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const n=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!n)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const n=this.devServerOrigins.get(e);if(n)return n;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=snapKey(n,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:n});this.sendWatch(e,n)}sendWatch(e,n){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(n))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:n}of this.watching.values()){this.sendWatch(e,n)}}rearmReconnectedDevices(e){for(const n of e){if(!n.deviceId||n.isConnected===false)continue;const t=String(n.deviceId);const r=`${n.id}|${n.connectionNonce??""}`;const s=this.deviceConnections.get(t);this.deviceConnections.set(t,r);if(s===void 0||s===r)continue;for(const{toolId:o,targetDeviceId:i}of this.watching.values()){if(i===t)this.sendWatch(o,i)}}}unwatch(e,n){const t=snapKey(n,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:n,watching:false})}cachedSnapshot(e,n){this.requireAdmission();this.requireDeviceAccount(n);return this.snapshots.get(snapKey(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=snapKey(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>RECOVERY_PROBE_MS){u.lastFullBudgetAt=Date.now()}else{o=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const i=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const a=await i;this.requireAdmission();this.requireDeviceAccount(r);if(a!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:a}}if(!t.silent)this.noteTimeout(r);const c=this.snapshots.get(s);if(c!==void 0){return{deviceId:r,payload:c,stale:this.staleNote(e,r)}}throw new Error(`Timed out waiting for "${e}" state from ${r} (is the tool active?). ${this.unresponsiveNote(r)}`)}staleNote(e,n){const t=this.snapshotAgeMs(e,n);const r=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${r}) \u2014 ${this.unresponsiveNote(n)}`}waitForSnapshot(e,n){return new Promise(t=>{const r=this.snapshotWaiters.get(e)??[];const s=setTimeout(()=>{const i=(this.snapshotWaiters.get(e)??[]).filter(a=>a!==o);this.snapshotWaiters.set(e,i);t(void 0)},n);const o=i=>{clearTimeout(s);t(i)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const i=(0,import_node_crypto2.randomUUID)();const a=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(i);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${a}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},a);this.pendingActions.set(i,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:i,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==ALL_DEVICES&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var snapKey=(e,n)=>`${e}::${n}`;function probeBroker(e,n=800){return new Promise(t=>{const r=import_node_http.default.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function waitForBroker(e,n=20){for(let t=0;t<n;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(n=>setTimeout(n,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod31=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.49"?"7.0.49":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,n){const t=o=>o.split("-")[0].split(".").map(i=>Number(i)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const i=r[o]??0;const a=s[o]??0;if(i>a)return true;if(i<a)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function fetchLatest(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),FETCH_TIMEOUT_MS);try{const r=await fetch(REGISTRY_URL,{signal:n.signal});if(!r.ok)return null;const s=await r.json();const o=typeof s.version==="string"?s.version:null;if(o){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const n=await readCache();if(n&&e-n.checkedAt<CHECK_TTL_MS)return n.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const n=await resolveLatest(e);if(n&&isNewer(n,VERSION)){return`A newer buoy-mcp is available (${n}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,n){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const r=n.listDevices();if(r.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const s=r.filter(c=>c.isConnected);const o=s.some(c=>c.isPro);const i=s.some(c=>c.tier!=="locked");const a=s.filter(c=>c.collision);return json({devices:r,...a.length>0?{warning:`${a.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...o?{}:{hint:i?"The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const r=n.toolActions(t);if(r.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:r})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:r,timeoutMs:s})=>{try{const o=n.requireProDevice(r);const{payload:i}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:i})}catch(o){return fail(errMsg(o))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:i})=>{try{const a=n.requireProDevice(o);const c=await n.callAction(t,r,s,a,{timeoutMs:i});return json({toolId:t,action:r,ok:true,result:c??null})}catch(a){return fail(errMsg(a))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,n){return typeof e==="number"&&e>0?e:n}function staleHeader(e){return e?`${e}
|
|
3
3
|
|
|
4
4
|
`:""}function inactiveToolMessage(e){return`No ${e.toolId} result from the device. The ${e.toolId} tool may be inactive, or the app needs a newer Buoy (${e.mcpTool} requires the ${e.action} action).`}function withDevice(e,n){return async t=>{try{const r=e.requireProDevice(t.deviceId);return await n(t,r)}catch(r){return fail2(errMsg2(r))}}}var PERF_TOOL_ID="perf-monitor";var clamp=(e,n,t)=>Math.max(n,Math.min(t,e));var WARMUP_CASE_NAME="__batch_warmup__";var THERMAL_MARK={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var FALLBACK={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function pick(e,n,t){return e??n??t}function buildAutomationConfig(e,n={}){const t=[];const r=(e.targetRoute||"").trim();if(!r)throw new Error("targetRoute is required (the screen to benchmark).");let s=(e.bounceRoute||"").trim();if(!s)s=r==="/"?"/_buoy_bounce":"/";if(s===r){throw new Error(`bounceRoute ("${s}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`)}if(!Array.isArray(e.cases)||e.cases.length===0){throw new Error("At least one case is required.")}const o=e.cases.map((d,h)=>{const y=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:y,params:d.params??{},route:d.route}});const i=clamp(Math.round(pick(e.runsPerCase,n.runsPerCase,FALLBACK.runsPerCase)),1,10);const a=clamp(Math.round(pick(e.coolDownMs,n.coolDownMs,FALLBACK.coolDownMs)),0,3e4);const c=clamp(Math.round(pick(e.discardWarmupRuns,n.discardWarmupRuns,FALLBACK.discardWarmupRuns)),0,i-1);const u=e.shuffleCases??n.shuffleCases??o.length>3;if(o.length>3&&u===false){t.push("shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it.")}if(i===1){t.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.")}const l={targetRoute:r,bounceRoute:s,perCaseDurationMs:clamp(Math.round(pick(e.perCaseDurationMs,n.perCaseDurationMs,FALLBACK.perCaseDurationMs)),1e3,6e4),settleMs:clamp(Math.round(pick(e.settleMs,n.settleMs,FALLBACK.settleMs)),0,1e4),navTimeoutMs:clamp(Math.round(pick(e.navTimeoutMs,n.navTimeoutMs,FALLBACK.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,discardWarmupCase:e.discardWarmupCase??n.discardWarmupCase??true,shuffleCases:u,reloadBetweenCases:e.reloadBetweenCases??n.reloadBetweenCases??false,captureRenders:e.captureRenders??n.captureRenders??true};return{config:l,warnings:t}}function describeBenchmarkSettings(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=estimateBatchMs(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const i=[`**${n(e.perCaseDurationMs)} measured per page**`,t>1?`**${t} runs each** (median taken)`:`**1 run each** (no median \u2014 noisier)`,e.reloadBetweenCases?"**full reload between pages** (no state leaks across tests)":"no reload between pages",e.shuffleCases?"shuffled order (cancels thermal bias)":"fixed order",e.captureRenders!==false?"render capture on (per-component why-data)":"render capture off"];const a=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${i.join(", ")}. That's ~${n(a)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function estimateBatchMs(e){const n=1800;const t=e.settleMs+e.perCaseDurationMs+e.coolDownMs+n;let r=e.cases.length*(e.runsPerCase??1)*t;if(e.reloadBetweenCases)r+=e.cases.length*3e3;return r}function computeFlags(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.uiFps!=null&&t&&e.uiFps<t*.95){n.push("UI thread drops frames")}else if(e.uiJank!=null&&e.uiJank>=2){n.push("UI jank spikes")}if(e.cpu!=null&&e.cpu>=90)n.push("CPU near cap");if(e.jsJank!=null&&e.jsJank>=5)n.push("JS thread stalls");return n}function computeWebFlags(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Main thread drops frames")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}if(e.frameGapMaxMs!=null&&e.frameGapMaxMs>=100){n.push(`Freeze of ${Math.round(e.frameGapMaxMs)} ms`)}if(e.longTaskPct!=null&&e.longTaskPct>=20)n.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50){n.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`)}if(e.styleLayoutMs!=null&&e.styleLayoutMs>=100){n.push(`Style and layout in slow frames (${Math.round(e.styleLayoutMs)} ms)`)}if(e.worstInteractionMs!=null&&e.worstInteractionMs>=200){n.push(`Slow response to input (${Math.round(e.worstInteractionMs)} ms)`)}if(e.layoutShift!=null&&e.layoutShift>=.1)n.push(`Layout shifts (${e.layoutShift})`);if(e.domGrowth!=null&&e.domGrowth>0)n.push(`DOM grows each run (+${e.domGrowth} nodes)`);return n}function domGrowthAcrossRuns(e){const n=e.filter(r=>!r.batchFailureReason).sort((r,s)=>(r.runIndex??0)-(s.runIndex??0)).map(r=>r.webRun?.domNodesStart).filter(r=>typeof r==="number");if(n.length<3)return null;for(let r=1;r<n.length;r++)if(n[r]<=n[r-1])return null;const t=n[n.length-1]-n[0];return t>=Math.max(50,n[0]*.05)?t:null}function pickBaseline(e,n){const t=e.find(s=>s.isBaseline);if(t)return t;const r=n?.[0]?.name;if(r){const s=e.filter(o=>o.caseName===r);if(s.length===1)return s[0]}return e[0]}function rankBatchFromIndex(e,n,t,r=[]){const s=e.filter(p=>p.batchId===n);const o=new Map(r.map(p=>[p.index,p.reason]));const i=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,f)=>p-f);const a=p=>{const f=s.filter(w=>(w.batchIndex??0)===p);if(f.length===0)return void 0;return f.find(w=>w.isMedianRun===true)??f.find(w=>w.runIndex===0||w.runIndex===void 0)??f[0]};const c=p=>typeof p==="number"?p:null;const u=i.map(p=>{const f=a(p);const w=s.filter(I=>(I.batchIndex??0)===p);const b=w.length;const _=w.filter(I=>!I.batchFailureReason);const k=I=>I.webRun?I.jsFpsAvg:I.uiFpsAvg;const $=_.map(k).filter(I=>typeof I==="number");const A=f?.name??t?.[p]?.name??`case ${p+1}`;const R=s.some(I=>(I.batchIndex??0)===p&&I.isBaseline===true);const T=o.get(p)??f?.batchFailureReason;const S=Boolean(T)||!f;const x={batchIndex:p,caseName:A,params:f?.params??{},runs:b,failed:S,...R?{isBaseline:true}:{},failureReason:S?T??"no run recorded":void 0,uiFps:c(f?.uiFpsAvg),jsFps:c(f?.jsFpsAvg),cpu:c(f?.cpuAvg),memMb:c(f?.memMaxMb),jsJank:c(f?.jsJankFrames),uiJank:c(f?.uiJankFrames),deviceMaxRefreshRate:c(f?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:_.length,fpsMin:$.length?round(Math.min(...$),1):null,fpsMax:$.length?round(Math.max(...$),1):null,...f?.thermalState?{thermalState:f.thermalState}:{},renderCommits:c(f?.renderCommits),renderWasted:f?.renderWasted,topRenderers:f?.topRenderers??[]};const M=f?.webRun;if(!M)return{...x,flags:S?[]:computeFlags(x)};const O=[...M.longTasksSupported?[]:["long tasks"],...M.heapSupported?[]:["JS heap"],...M.loaf===null?["long animation frames"]:[]];const z={...x,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:M.heapSupported?x.memMb:null,deviceMaxRefreshRate:Math.max(x.deviceMaxRefreshRate??0,M.refreshHz??0)||null,longTaskPct:M.longTaskPct,frameGapP95Ms:M.frameGapP95Ms,frameGapMaxMs:M.frameGapMaxMs,longFrames:M.longFrames,...O.length?{missingMetrics:O}:{},loafBlockingMs:M.loaf?.blockingMs??null,styleLayoutMs:M.loaf?.styleLayoutMs??null,forcedLayoutMs:M.loaf?.forcedLayoutMs??null,topScripts:M.loaf?.topScripts??[],worstInteractionMs:M.interactions?.worstMs??null,layoutShift:M.layoutShift??null,domNodes:M.domNodesEnd??null,domGrowth:domGrowthAcrossRuns(w),pageLoad:M.pageLoad??null};return{...z,flags:S?[]:computeWebFlags({...z,frames:M.frames})}}).filter(p=>p.caseName!==WARMUP_CASE_NAME).map((p,f)=>({...p,batchIndex:f}));const l=pickBaseline(u,t);if(l){l.isBaseline=true;for(const p of u){if(p===l)continue;if(p.uiFps!=null&&l.uiFps!=null)p.deltaUiFps=round(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=round(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,f)=>{let w;let b=NaN;for(const _ of d){const k=p(_);if(k==null)continue;if(w===void 0||f(k,b)){w=_;b=k}}return w?.caseName};const y=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const v={uiFps:h(p=>p.uiFps,(p,f)=>p>f),jsFps:h(p=>p.jsFps,(p,f)=>p>f),lowestCpu:h(p=>p.cpu,(p,f)=>p<f),lowestMem:h(p=>p.memMb,(p,f)=>p<f),fewestJank:h(y,(p,f)=>p<f),fewestLongTasks:h(p=>p.longTaskPct??null,(p,f)=>p<f)};const m=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const g=d.filter(p=>p.flags.length===0).sort((p,f)=>(f.uiFps??f.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(f.cpu??f.longTaskPct??Infinity)||(p.memMb??Infinity)-(f.memMb??Infinity)).map(p=>p.caseName);return{batchId:n,baselineName:l?.caseName??null,deviceMaxRefreshRate:d.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:v,flagged:m,balanced:g}}var FRESH_LOAD_MS=2e4;function pageLoadLines(e){const n=e.filter(r=>!r.failed);if(n.length===0||!n.every(r=>r.pageLoad&&r.pageLoad.pageAgeMs<FRESH_LOAD_MS)){return[]}const t=r=>r==null?"\u2014":String(Math.round(r));return[`**Page load** (each case started on a fresh page load):`,"",`| Case | TTFB ms | FCP ms | LCP ms | DOM ready ms | Script KB | Total KB |`,`|------|--------:|-------:|-------:|-------------:|----------:|---------:|`,...n.map(r=>{const s=r.pageLoad;return`| ${r.caseName} | ${t(s.ttfbMs)} | ${t(s.fcpMs)} | ${t(s.lcpMs)} | ${t(s.domContentLoadedMs)} | ${t(s.scriptKb)} | ${t(s.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function closeCallNote(e){const n=o=>o.platform==="web"?o.jsFps:o.uiFps;const t=e.filter(o=>!o.failed&&n(o)!=null&&o.fpsMin!=null&&o.fpsMax!=null).sort((o,i)=>(n(i)??0)-(n(o)??0));if(t.length<2)return null;const[r,s]=t;if((r.validRuns??0)<2||(s.validRuns??0)<2){return`**Too few runs to separate the top cases.** ${r.caseName} and ${s.caseName} each need 2+ valid runs before their FPS gap means anything.`}if((s.fpsMax??0)>=(r.fpsMin??0)){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap across runs. Run more repeats before picking between them.`}return null}function formatRankingMarkdown(e){const n=(h,y=1)=>h==null?"\u2014":h.toFixed(y);const t=h=>h==null?"":h>0?` (+${h.toFixed(1)})`:` (${h.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(h=>h.platform==="web")){formatWebTable(e,r);return r.join("\n")}r.push(`**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`);r.push("");const s=e.rows.some(h=>h.thermalState);const o=s?" Thermal |":"";const i=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${i}-------|`);for(const h of e.rows){const y=s?` ${THERMAL_MARK[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${y} \u274C FAILED (${h.failureReason}) |`);continue}const v=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"\u2713 clean";r.push(`| ${h.batchIndex} | ${h.caseName} | ${n(h.uiFps)}${t(h.deltaUiFps)} | ${n(h.jsFps)}${t(h.deltaJsFps)} | ${n(h.cpu)} | ${n(h.memMb)} | ${n(h.jsJank,0)} | ${n(h.uiJank,0)} |${y} ${v} |`)}const a=closeCallNote(e.rows);if(a){r.push("");r.push(a)}const c=e.rows.filter(h=>h.thermalState==="serious"||h.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(h=>h.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`)}r.push("");const u=e.leaders;const l=[u.uiFps&&`UI FPS \u2192 **${u.uiFps}**`,u.jsFps&&`JS FPS \u2192 **${u.jsFps}**`,u.lowestCpu&&`lowest CPU \u2192 **${u.lowestCpu}**`,u.lowestMem&&`lowest mem \u2192 **${u.lowestMem}**`,u.fewestJank&&`fewest jank \u2192 **${u.fewestJank}**`].filter(Boolean).join(" \xB7 ");if(l)r.push(`**Per-metric best:** ${l}`);r.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);if(e.flagged.length){r.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(h=>`${h.caseName} (${h.flags.join("; ")})`).join(" \xB7 ")}`)}const d=e.rows.filter(h=>!h.failed&&(h.renderCommits!=null||h.topRenderers.length>0)).map(h=>{const y=h.topRenderers.map(g=>`${g.name} \xD7${g.renders} (${g.totalMs}ms${g.cause?`, ${g.cause}`:""})`).join(", ");const v=h.renderCommits!=null?`${h.renderCommits} commits`:"";const m=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[v,m,y].filter(Boolean).join(": ")}`});if(d.length){r.push("");r.push("**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream.");r.push(...d)}r.push("");r.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`);return r.join("\n")}function compareReportStats(e,n){const t=(r,s)=>({a:{avg:round(r.avg,1),p95:round(r.p95,1)},b:{avg:round(s.avg,1),p95:round(s.p95,1)},deltaAvg:round(s.avg-r.avg,1)});return{a:e.name,b:n.name,uiFps:t(e.stats.uiFps,n.stats.uiFps),jsFps:t(e.stats.jsFps,n.stats.jsFps),cpuUsage:t(e.stats.cpuUsage,n.stats.cpuUsage),memoryUsage:t(e.stats.memoryUsage,n.stats.memoryUsage),jsJankFrames:{a:e.stats.jsJankFrames,b:n.stats.jsJankFrames,delta:n.stats.jsJankFrames-e.stats.jsJankFrames},uiJankFrames:{a:e.stats.uiJankFrames,b:n.stats.uiJankFrames,delta:n.stats.uiJankFrames-e.stats.uiJankFrames},...compareRenders(e.renders,n.renders)}}function compareRenders(e,n){if(!e&&!n)return null;const t=r=>r??0;return{renders:{total:{a:t(e?.totalRenders),b:t(n?.totalRenders),delta:t(n?.totalRenders)-t(e?.totalRenders)},commits:{a:t(e?.totalCommits),b:t(n?.totalCommits),delta:t(n?.totalCommits)-t(e?.totalCommits)},wasted:{a:t(e?.wastedRenders),b:t(n?.wastedRenders),delta:t(n?.wastedRenders)-t(e?.wastedRenders)},causes:{a:e?.causes??{},b:n?.causes??{}}}}}function round(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function formatWebTable(e,n){const t=(l,d=1)=>l==null?"\u2014":l.toFixed(d);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**No single winner \u2014 weigh the trade-offs.** This is a browser run. FPS is the main thread's frame rate (requestAnimationFrame); it can't see frames dropped later by the GPU or compositor. Long tasks % is time the main thread was blocked by tasks over 50 ms, not CPU use.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | Heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const d=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${d} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const h=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"\u2713 clean";n.push(`| ${l.batchIndex} | ${d} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${h} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const d=[l.loafBlockingMs!=null&&l.loafBlockingMs>0&&`slow frames blocked input ${l.loafBlockingMs} ms`,l.styleLayoutMs!=null&&l.styleLayoutMs>0&&`style + layout ${l.styleLayoutMs} ms`,l.forcedLayoutMs!=null&&l.forcedLayoutMs>0&&`forced layout ${l.forcedLayoutMs} ms`,l.worstInteractionMs!=null&&`slowest input ${l.worstInteractionMs} ms`,l.layoutShift!=null&&l.layoutShift>0&&`layout shift ${l.layoutShift}`,l.domNodes!=null&&`${l.domNodes} DOM nodes${l.domGrowth?` (+${l.domGrowth} over the runs)`:""}`,l.topScripts&&l.topScripts.length>0&&`top scripts: ${l.topScripts.slice(0,3).map(h=>`${h.name} ${h.ms} ms`).join(", ")}`].filter(Boolean);return d.length?`- **${l.caseName}**: ${d.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size):`);n.push(...s)}const o=pageLoadLines(e.rows);if(o.length){n.push("");n.push(...o)}const i=closeCallNote(e.rows);if(i){n.push("");n.push(i)}const a=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(a.length){n.push("");n.push(`**Not measured in this browser:** ${a.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.deviceMaxRefreshRate){n.push("");n.push(`Screen rate: ${e.deviceMaxRefreshRate} Hz. A frame gap over ${(1500/e.deviceMaxRefreshRate).toFixed(1)} ms missed at least one frame.`)}n.push("");const c=e.leaders;const u=[c.jsFps&&`FPS \u2192 **${c.jsFps}**`,c.fewestJank&&`fewest long frames \u2192 **${c.fewestJank}**`,c.fewestLongTasks&&`fewest long tasks \u2192 **${c.fewestLongTasks}**`,c.lowestMem&&`lowest heap \u2192 **${c.lowestMem}**`].filter(Boolean).join(" \xB7 ");if(u)n.push(`**Per-metric leaders:** ${u}`);if(e.flagged.length){n.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(l=>`${l.caseName} (${l.flags.join("; ")})`).join(" \xB7 ")}`)}n.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);n.push("");n.push(`_Deltas vs baseline (\`${e.baselineName??"case #0"}\`). Confirm finalists on a real phone browser: desktop numbers don't predict mobile GPU, memory or heat._`)}var POLL_INTERVAL_MS=1500;var delay2=e=>new Promise(n=>setTimeout(n,e));var json2=e=>({content:[{type:"text",text:JSON.stringify(e,null,2)}]});var caseSchema=import_zod3.z.object({name:import_zod3.z.string().describe("Display name for this variant (becomes the saved run's name)."),params:import_zod3.z.record(import_zod3.z.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),route:import_zod3.z.string().optional().describe("Optional per-case route override; defaults to targetRoute.")});async function readDeviceSettings(e,n){try{const t=await e.callAction(PERF_TOOL_ID,"getAutomationConfig",{},n);if(!t)return{};const{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:i,coolDownMs:a,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}}catch{return{}}}async function readPerfSnapshot(e,n){const{payload:t}=await e.getSnapshot(PERF_TOOL_ID,n,{timeoutMs:4e3});return t}function batchIdsIn(e){const n=new Set;for(const t of e??[])if(t.batchId)n.add(t.batchId);return n}function runCountFor(e,n){return(e??[]).filter(t=>t.batchId===n).length}function newestBatchId(e){let n=null;for(const t of e??[]){if(t.batchId&&(!n||t.createdAt>n.at))n={id:t.batchId,at:t.createdAt}}return n?.id??null}function failuresFromIndex(e,n){const t=new Map;for(const r of e??[]){if(r.batchId===n&&r.batchFailureReason&&!t.has(r.batchIndex??0)){t.set(r.batchIndex??0,r.batchFailureReason)}}return[...t.entries()].map(([r,s])=>({index:r,reason:s}))}function expectedRunsFor(e){const n=e.cases.length+(e.discardWarmupCase===true?1:0);return n*(e.runsPerCase??1)}var SETTLE_WAIT_MS=1e4;function isRunnerBusy(e){return!!e&&e!=="idle"&&e!=="done"&&e!=="cancelled"}async function waitForRunnerSettled(e,n,t,r=SETTLE_WAIT_MS){const s=Date.now();while(Date.now()-s<r){const o=await readPerfSnapshot(e,n).catch(()=>null);if(o&&!isRunnerBusy(o.live?.automation?.phase))return true;await delay2(POLL_INTERVAL_MS)}t(`automation still busy after ${Math.round(r/1e3)}s`);return false}async function waitForBatchByIndex(e,n,t){const{preBatchIds:r,expectedRuns:s,maxWaitMs:o,log:i}=t;const a=Date.now();let c="";let u=false;let l=0;while(Date.now()-a<o){let y;try{y=await readPerfSnapshot(e,n)}catch{await delay2(POLL_INTERVAL_MS);continue}const v=y.index??[];const m=y.live?.automation?.phase??"idle";const g=y.live?.automationCompleted;if(g?.phase==="cancelled"&&!r.has(g.batchId)){return{batchId:g.batchId,failures:[],cancelled:true}}if(g?.phase==="done"&&!r.has(g.batchId)){return{batchId:g.batchId,failures:g.failures??failuresFromIndex(v,g.batchId)}}if(m!=="idle")u=true;if(m!==c){c=m;i(`automation phase: ${m}`)}const p=[...batchIdsIn(v)].find(f=>!r.has(f));if(p){const f=runCountFor(v,p);if(s>0&&f>=s){await waitForRunnerSettled(e,n,i);i(`batch ${p} complete (${f}/${s} runs saved)`);return{batchId:p,failures:failuresFromIndex(v,p)}}if(m==="idle"&&u&&f>0){if(++l>=3){i(`batch ${p} settled (${f} runs, automation idle)`);return{batchId:p,failures:failuresFromIndex(v,p)}}}else{l=0}}await delay2(POLL_INTERVAL_MS)}const d=await readPerfSnapshot(e,n).catch(()=>null);const h=d?[...batchIdsIn(d.index)].find(y=>!r.has(y)):void 0;if(h&&d)return{batchId:h,failures:failuresFromIndex(d.index,h),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${c||"idle"}). Check get_automation_status \u2014 the device may not have navigated.`)}function registerPerfMonitorTools(e,n){e.registerTool("run_benchmark_batch",{description:"Run a perf-monitor benchmark batch and return per-metric leaders, risk flags and a comparison of available case results. Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",inputSchema:{targetRoute:import_zod3.z.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),cases:import_zod3.z.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),bounceRoute:import_zod3.z.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),runsPerCase:import_zod3.z.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),perCaseDurationMs:import_zod3.z.number().optional().describe("How long to record each run. Default 5000."),settleMs:import_zod3.z.number().optional().describe("Idle gap after navigation before recording. Default 600."),coolDownMs:import_zod3.z.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),navTimeoutMs:import_zod3.z.number().optional().describe("Max wait for navigation per run. Default 5000."),discardWarmupRuns:import_zod3.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),shuffleCases:import_zod3.z.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),reloadBetweenCases:import_zod3.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),captureRenders:import_zod3.z.boolean().optional().describe("Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds instrumentation overhead that can vary with the workload. Default true."),deviceId:import_zod3.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),maxWaitMs:import_zod3.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")}},async t=>{const r=s=>process.stderr.write(`[buoy-mcp] ${s}
|
|
5
5
|
`);try{const s=n.requireProDevice(t.deviceId);const o=await readDeviceSettings(n,s);const{config:i,warnings:a}=buildAutomationConfig(t,o);const c=estimateBatchMs(i);const u=t.maxWaitMs??c*3+6e4;const l=expectedRunsFor(i);if(!await waitForRunnerSettled(n,s,r)){return fail2("Another benchmark batch is still running on this device. Wait for it to finish, or cancel it (call_action perf-monitor cancelAutomation), then retry.")}await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:true},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const d=await readPerfSnapshot(n,s);const h=batchIdsIn(d.index);r(`starting batch: ${i.cases.length} cases \xD7 ${i.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(PERF_TOOL_ID,"startAutomation",{config:i},s);const y=await waitForBatchByIndex(n,s,{preBatchIds:h,expectedRuns:l,maxWaitMs:u,log:r});if(y.cancelled){await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});return fail2(`Batch ${y.batchId} was cancelled before completing.`)}await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const v=await readPerfSnapshot(n,s);const m=rankBatchFromIndex(v.index??[],y.batchId,i.cases,y.failures);await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:false},s).catch(()=>{});if(m.rows.length===0){const f=n.connectedDevices().some(b=>b.deviceId===s);const w=!f?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:y.timedOut?`No runs were saved before the wait cap (${Math.round(u/1e3)}s). On web, a hidden or background tab pauses the run: keep the tab in front. Check get_automation_status for the device's phase.`:`The batch finished without saving any runs. Check get_automation_status and the device console.`;return fail2(`Benchmark batch ${y.batchId||"(no id)"} saved no runs. ${w}`)}const g=formatRankingMarkdown(m);const p={batchId:y.batchId,leaders:m.leaders,balanced:m.balanced,flagged:m.flagged,rows:m.rows,failures:y.failures,warnings:a,estimateMs:c,...y.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return text(`${g}
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{ALL_DEVICES as e,BROKER_PORT as o,BROKER_URL as r,DASHBOARD_NAME as t,DEFAULT_ACTION_TIMEOUT_MS as s,DEFAULT_SNAPSHOT_TIMEOUT_MS as i,PACKAGE_NAME as T,SyncClient as n,VERSION as a,buildAutomationConfig as g,compareReportStats as l,createMcpServer as E,estimateBatchMs as R,formatRankingMarkdown as A,getUpdateNotice as S,rankBatchFromIndex as _,registerConsoleTools as c,registerEventsTools as M,registerGenericTools as O,registerJotaiTools as m,registerPerfMonitorTools as p,registerReactQueryTools as u,registerReduxTools as I,registerRoutesTools as d,registerScreenshotTools as C,registerStorageTools as N,registerZustandTools as U,runInit as B}from"./chunk-
|
|
1
|
+
import{ALL_DEVICES as e,BROKER_PORT as o,BROKER_URL as r,DASHBOARD_NAME as t,DEFAULT_ACTION_TIMEOUT_MS as s,DEFAULT_SNAPSHOT_TIMEOUT_MS as i,PACKAGE_NAME as T,SyncClient as n,VERSION as a,buildAutomationConfig as g,compareReportStats as l,createMcpServer as E,estimateBatchMs as R,formatRankingMarkdown as A,getUpdateNotice as S,rankBatchFromIndex as _,registerConsoleTools as c,registerEventsTools as M,registerGenericTools as O,registerJotaiTools as m,registerPerfMonitorTools as p,registerReactQueryTools as u,registerReduxTools as I,registerRoutesTools as d,registerScreenshotTools as C,registerStorageTools as N,registerZustandTools as U,runInit as B}from"./chunk-MLEZPZP3.mjs";import"./chunk-2PTJT2QF.mjs";export{e as ALL_DEVICES,o as BROKER_PORT,r as BROKER_URL,t as DASHBOARD_NAME,s as DEFAULT_ACTION_TIMEOUT_MS,i as DEFAULT_SNAPSHOT_TIMEOUT_MS,T as PACKAGE_NAME,n as SyncClient,a as VERSION,g as buildAutomationConfig,l as compareReportStats,E as createMcpServer,R as estimateBatchMs,A as formatRankingMarkdown,S as getUpdateNotice,_ as rankBatchFromIndex,c as registerConsoleTools,M as registerEventsTools,O as registerGenericTools,m as registerJotaiTools,p as registerPerfMonitorTools,u as registerReactQueryTools,I as registerReduxTools,d as registerRoutesTools,C as registerScreenshotTools,N as registerStorageTools,U as registerZustandTools,B as runInit};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buoy-gg/mcp",
|
|
3
3
|
"mcpName": "io.github.Buoy-gg/mcp",
|
|
4
|
-
"version": "7.0.
|
|
4
|
+
"version": "7.0.49",
|
|
5
5
|
"buoy": {
|
|
6
6
|
"dashboardAuthVersion": 1
|
|
7
7
|
},
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"socket.io-client": "^4.8.1",
|
|
36
36
|
"zod": "^3.25.0",
|
|
37
37
|
"ws": "^8.18.0",
|
|
38
|
-
"@buoy-gg/sync-broker": "^7.0.
|
|
39
|
-
"@buoy-gg/license": "^7.0.
|
|
38
|
+
"@buoy-gg/sync-broker": "^7.0.49",
|
|
39
|
+
"@buoy-gg/license": "^7.0.49"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "^22.0.0",
|