@buoy-gg/mcp 7.0.50 → 7.0.52
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-QIYTG2A5.mjs → chunk-JULJYHPG.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{describeDevices as Ur,describeDiagnosis as zr,describeInputs as Wr,proRequired as Jr,readEntitlement as ut,run as X}from"./chunk-RYFN5J56.mjs";import{formatWebBenchMarkdown as Gr,runWebBench as Vr}from"./chunk-EKM5ZZKU.mjs";import{DEFAULT_BROKER_PORT as Kr}from"@buoy-gg/sync-broker";var dt=Kr;var Ht=`http://localhost:${dt}`;var Yt=15e3;var Hr=2500;var Yr=1200;var Qt=2e4;var Qr=3e4;var Xt=5e3;var Xr=15*6e4;var ht="Dashboard";var Z="All";import{readFileSync as Zt,writeFileSync as Zr,mkdirSync as es,renameSync as ts,unlinkSync as ns}from"fs";import{dirname as rs,join as en,resolve as ss}from"path";import{homedir as os}from"os";import{createHash as as}from"crypto";import{validateLicense as is,isAcceptedValidation as cs,isTransientLicenseError as ls,resolveTier as us,isRecognizedAccount as ds,isBuoyToken as hs,verifyFreeToken as ps,resolveAccountAccess as ms,signCache as Ne,verifyCacheSignature as fs,LICENSE_LAUNCH_VALIDATION_TTL as tn,LICENSE_REVALIDATION_INTERVAL as me,LICENSE_TRANSIENT_RETRY_DELAYS as nn,KEYGEN_CONFIG as gs,createNodeSession as vs,CLI_AUDIENCE as ws,exchangeServiceKey as bs,isServiceKey as ys,normalizeLicenseKey as rn,checkDevToken as $s,isDevToken as ks}from"@buoy-gg/license/headless";var sn=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function pt(e,n=process.env){for(const t of sn)if(n[t]!==void 0)return rn(n[t])||null;for(const t of[".env.development.local",".env.local",".env"]){let r;try{r=Zt(en(e,t),"utf8")}catch{continue}const s=new Map;for(const o of r.split(/\r?\n/)){const a=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!a)continue;const i=a[2].trim();const c=i.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(a[1],c?c[2]:i.split("#")[0].trim())}for(const o of sn)if(s.has(o))return rn(s.get(o))||null}return null}var _s=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=ss(n.projectDir??process.env.BUOY_PROJECT_DIR??process.cwd());const t=as("sha256").update(this.projectDir).digest("hex").slice(0,20);this.cacheFile=n.cacheFile??en(os(),".buoy",`account-${t}.json`);this.session=n.session!==void 0?n.session:vs({audience:ws,openBrowser:()=>{throw new Error("The MCP does not open a browser. Run `npx buoy login`.")}})}hasKeyAccess(){return ms({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(Zt(this.cacheFile,"utf8"));if(e.valid&&fs(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=pt(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<tn&&this.nextCheck===0){this.nextCheck=this.cache.lastValidatedAt+tn;return}this.busy=true;const n=this.generation;try{if(ks(e)){const i=$s(e);if(!i.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),i.claims.iat*1e3);const u=i.claims.tier==="free"?"free":"pro";this.cache=Ne({licenseKey:e,licenseId:`dev:${i.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(i.claims.exp*1e3).toISOString(),cachedAt:c,lastValidatedAt:Date.now()});this.persistCache()}this.nextCheck=Date.now()+me;return}if(ys(e)){const i=await bs(e,{origin:process.env.BUOY_SITE_URL||"https://buoy.gg"});if(n!==this.generation||pt(this.projectDir)!==e)return;if(!i.ok){if(i.reason==="rejected"){this.cache=null;this.removeCache();this.nextCheck=Date.now()+me}else{this.nextCheck=Date.now()+(nn[this.retry++]??me)}return}const c=Date.now();const u=i.claims.exp*1e3;this.cache=Ne({licenseKey:e,licenseId:`service:${i.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(hs(e)){const i=ps(e);if(!i){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=Ne({licenseKey:e,licenseId:`token:${i.accountHash}`,valid:true,tier:"free",isPro:false,expiresAt:null,cachedAt:c,lastValidatedAt:c});this.persistCache()}this.nextCheck=Date.now()+me;return}const t=await is(e);if(n!==this.generation||pt(this.projectDir)!==e)return;const r=t.data?.relationships?.product?.data?.id;const s=t.meta.code==="EXPIRED"&&(ds(t.data)||this.cache?.licenseKey===e);if(r&&r!==gs.productId||!s&&!cs(t)){this.cache=null;this.removeCache();this.nextCheck=Date.now()+me;return}const o=s?"free":us(t.data);const a=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:a,lastValidatedAt:a});this.persistCache();this.retry=0;this.nextCheck=a+me}catch(t){if(n!==this.generation)return;const r=ls(t)?nn[this.retry++]:void 0;this.nextCheck=Date.now()+(r??me)}finally{this.busy=false;this.notify()}}persistCache(){try{es(rs(this.cacheFile),{recursive:true,mode:448});Zr(`${this.cacheFile}.tmp`,JSON.stringify(this.cache),{mode:384});ts(`${this.cacheFile}.tmp`,this.cacheFile)}catch{}}removeCache(){try{ns(this.cacheFile)}catch{}}};import Ss from"http";import{randomUUID as xs}from"crypto";import{io as As}from"socket.io-client";import{createBroker as Ts,brokerTokenPath as mt,readBrokerToken as Ms,writeBrokerToken as Is,DASHBOARD_TOKEN_REQUIRED as Ds}from"@buoy-gg/sync-broker";function Rs(e,n){return e==="app"&&(n==="ping"||n==="reloadApp")}function on(e){return e>=2}function Os(e){if(Rs(e.toolId,e.action)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(!on(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 Cs(e){if(e.consecutiveTimeouts>=2)return true;if(e.lastOkAt>0&&e.now-e.lastOkAt>e.unresponsiveAfterMs){return true}return false}var Fs=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??dt;this.url=e.url??(e.port?`http://localhost:${e.port}`:Ht);this.actionTimeoutMs=e.actionTimeoutMs??Yt;this.log=e.log??(n=>process.stderr.write(`[buoy-mcp] ${n}
|
|
2
|
-
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await an(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 _s(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=Ts({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await Es(this.url);try{Is(this.broker.dashboardToken,mt(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=As(this.url,{transports:["websocket"],query:{deviceName:ht},auth:i=>{const c=this.broker?.dashboardToken??Ms(mt(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===Ds;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${mt(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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=ce(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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===Z){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()-Xr;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!==ht&&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(a=>a.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===Z){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return Z}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 on(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=Os({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:Hr,recoveryProbeMs:Qt,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=Cs({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:Qr});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(ce(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(ce(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(ce(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=ce(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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}unwatch(e,n){const t=ce(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(ce(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=ce(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??Xt;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>Qt){u.lastFullBudgetAt=Date.now()}else{o=Yr}}const a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===Z?Z:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const a=xs();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(a);if(o!==Z&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===Z?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:a,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==Z&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var ce=(e,n)=>`${e}::${n}`;function an(e,n=800){return new Promise(t=>{const r=Ss.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function Es(e,n=20){for(let t=0;t<n;t++){if(await an(e,300))return;await Bs(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var Bs=e=>new Promise(n=>setTimeout(n,e));var fe="7.0.50"?"7.0.50":"0.0.0-dev";var le="@buoy-gg/mcp";import{promises as cn}from"fs";import Ps from"os";import js from"path";var qs=24*60*60*1e3;var Ns=1500;var Ls=`https://registry.npmjs.org/${le}/latest`;var ln=js.join(Ps.tmpdir(),"buoy-mcp-update-check.json");function Us(e,n){const t=o=>o.split("-")[0].split(".").map(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)return false}return false}async function zs(){try{const e=await cn.readFile(ln,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function Ws(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),Ns);try{const r=await fetch(Ls,{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 cn.writeFile(ln,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function Js(e){const n=await zs();if(n&&e-n.checkedAt<qs)return n.latest;return Ws(e)}async function un(e=Date.now()){if(fe==="0.0.0-dev")return null;try{const n=await Js(e);if(n&&Us(n,fe)){return`A newer buoy-mcp is available (${n}; you're on ${fe}). Update by re-running: npx -y ${le}@latest init \u2014 then restart your editor.`}}catch{}return null}import{z as ee}from"zod";function Re(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function ft(e){return{isError:true,content:[{type:"text",text:e}]}}function dn(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 un();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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return Re({devices:r,...i.length>0?{warning:`${i.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:a?"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:ee.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 ft(`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:ee.string().describe("The tool id to read state from."),deviceId:ee.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:ee.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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return Re({toolId:t,deviceId:o,snapshot:a})}catch(o){return ft(hn(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:ee.string().describe('The tool id, e.g. "perf-monitor".'),action:ee.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:ee.record(ee.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:ee.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:ee.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return Re({toolId:t,action:r,ok:true,result:c??null})}catch(i){return ft(hn(i))}})}function hn(e){return e instanceof Error?e.message:String(e)}var xe=(e,n,t)=>Math.max(n,Math.min(t,e));var pn="__batch_warmup__";var Gs={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var Ae={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function Te(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 k=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:k,params:d.params??{},route:d.route}});const a=xe(Math.round(Te(e.runsPerCase,n.runsPerCase,Ae.runsPerCase)),1,10);const i=xe(Math.round(Te(e.coolDownMs,n.coolDownMs,Ae.coolDownMs)),0,3e4);const c=xe(Math.round(Te(e.discardWarmupRuns,n.discardWarmupRuns,Ae.discardWarmupRuns)),0,a-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(a===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:xe(Math.round(Te(e.perCaseDurationMs,n.perCaseDurationMs,Ae.perCaseDurationMs)),1e3,6e4),settleMs:xe(Math.round(Te(e.settleMs,n.settleMs,Ae.settleMs)),0,1e4),navTimeoutMs:xe(Math.round(Te(e.navTimeoutMs,n.navTimeoutMs,Ae.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:a,coolDownMs:i,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 mn(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=gt(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const a=[`**${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 i=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${a.join(", ")}. That's ~${n(i)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function gt(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 Vs(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 Ks(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Low main-thread frame rate")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}const r=Math.max(e.frameGapMaxMs??0,e.edgeSilenceMs??0);if(r>=100)n.push(`Freeze of ${Math.round(r)} 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 grew every run (+${e.domGrowth} nodes): check for a leak`)}return n}function Hs(e){const n=e.filter(r=>!r.batchFailureReason&&!r.discardedWarmup).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 Ys(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 a=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,m)=>p-m);const i=p=>{const m=s.filter(_=>(_.batchIndex??0)===p);if(m.length===0)return void 0;const v=m.filter(_=>!_.batchFailureReason&&!_.discardedWarmup);const w=v.filter(_=>_.webRun?.fps!=null);if(w.length>0&&w.length===v.length){const _=[...w].sort((T,I)=>T.webRun.fps-I.webRun.fps);return _[Math.floor((_.length-1)/2)]}if(v.length>0){return v.find(_=>_.isMedianRun===true)??v.find(_=>_.runIndex===0||_.runIndex===void 0)??v[0]}return m.find(_=>_.batchFailureReason)??m[0]};const c=p=>typeof p==="number"?p:null;const u=a.map(p=>{const m=i(p);const v=s.filter(z=>(z.batchIndex??0)===p);const w=v.length;const _=v.filter(z=>!z.batchFailureReason&&!z.discardedWarmup);const T=z=>z.webRun?z.webRun.fps??z.jsFpsAvg:z.uiFpsAvg;const I=_.map(T).filter(z=>typeof z==="number");const F=m?.name??t?.[p]?.name??`case ${p+1}`;const lt=s.some(z=>(z.batchIndex??0)===p&&z.isBaseline===true);const C=o.get(p)??m?.batchFailureReason;const E=v.every(z=>z.batchFailureReason||z.discardedWarmup);const B=Boolean(C)||!m||E;const Q={batchIndex:p,caseName:F,params:m?.params??{},runs:w,failed:B,...m?.route?{route:m.route}:{},...lt?{isBaseline:true}:{},failureReason:B?C??(E&&m?"only warmup runs were saved":"no run recorded"):void 0,uiFps:c(m?.uiFpsAvg),jsFps:c(m?.jsFpsAvg),cpu:c(m?.cpuAvg),memMb:c(m?.memMaxMb),jsJank:c(m?.jsJankFrames),uiJank:c(m?.uiJankFrames),deviceMaxRefreshRate:c(m?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:_.length,fpsMin:I.length?H(Math.min(...I),1):null,fpsMax:I.length?H(Math.max(...I),1):null,...m?.thermalState?{thermalState:m.thermalState}:{},renderCommits:c(m?.renderCommits),renderWasted:m?.renderWasted,topRenderers:m?.topRenderers??[]};const P=m?.webRun;if(!P)return{...Q,flags:B?[]:Vs(Q)};const Vt=[...P.longTasksSupported?[]:["long tasks"],...P.heapSupported?[]:["JS heap"],...P.loaf===null?["long animation frames"]:[]];const Kt={...Q,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:P.heapSupported?Q.memMb:null,deviceMaxRefreshRate:Math.max(Q.deviceMaxRefreshRate??0,P.refreshHz??0)||null,longTaskPct:P.longTaskPct,frameGapP95Ms:P.frameGapP95Ms,frameGapMaxMs:P.frameGapMaxMs,edgeSilenceMs:P.edgeSilenceMs??null,jsFps:P.fps??Q.jsFps,longFrames:P.longFrames,...Vt.length?{missingMetrics:Vt}:{},loafBlockingMs:P.loaf?.blockingMs??null,styleLayoutMs:P.loaf?.styleLayoutMs??null,forcedLayoutMs:P.loaf?.forcedLayoutMs??null,topScripts:P.loaf?.topScripts??[],worstInteractionMs:P.interactions?.worstMs??null,layoutShift:P.layoutShift??null,domNodes:P.domNodesEnd??null,domGrowth:Hs(v),pageLoad:P.pageLoad??null,refreshCalibrated:P.refreshCalibrated??null};return{...Kt,flags:B?[]:Ks({...Kt,frames:P.frames})}}).filter(p=>p.caseName!==pn).map((p,m)=>({...p,batchIndex:m}));const l=Ys(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=H(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=H(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,m)=>{let v;let w=NaN;for(const _ of d){const T=p(_);if(T==null)continue;if(v===void 0||m(T,w)){v=_;w=T}}return v?.caseName};const k=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const b={uiFps:h(p=>p.uiFps,(p,m)=>p>m),jsFps:h(p=>p.jsFps,(p,m)=>p>m),lowestCpu:h(p=>p.cpu,(p,m)=>p<m),lowestMem:h(p=>p.memMb,(p,m)=>p<m),fewestJank:h(k,(p,m)=>p<m),fewestLongTasks:h(p=>p.longTaskPct??null,(p,m)=>p<m)};const g=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const y=d.filter(p=>p.flags.length===0).sort((p,m)=>(m.uiFps??m.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(m.cpu??m.longTaskPct??Infinity)||(p.memMb??Infinity)-(m.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:b,flagged:g,balanced:y}}function Qs(e){const n=e.filter(a=>!a.failed);if(n.length===0)return[];const t=n.map(a=>a.pageLoad?.navigationId);if(t.some(a=>a==null)||new Set(t).size!==t.length)return[];if(n.some(a=>(a.pageLoad?.pageAgeMs??Infinity)>3e4))return[];const r=a=>(a??"").replace(/\/+$/,"")||"/";const s=a=>{const i=a.pageLoad;if(!i?.loadPath||r(i.loadPath)!==r(a.route))return false;const c=new URLSearchParams(i.loadSearch??"");const u=[...c.keys()];if(new Set(u).size!==u.length)return false;const l=Object.entries(a.params);return u.length===l.length&&l.every(([d,h])=>c.get(d)===h)};if(n.some(a=>!s(a)))return[];const o=a=>a==null?"\u2014":String(Math.round(a));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(a=>{const i=a.pageLoad;return`| ${a.caseName} | ${o(i.ttfbMs)} | ${o(i.fcpMs)} | ${o(i.lcpMs)} | ${o(i.domContentLoadedMs)} | ${o(i.scriptKb)} | ${o(i.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function fn(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,a)=>(n(a)??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)||(n(r)??0)-(n(s)??0)<=2){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap or sit within 2 FPS. No clear winner between them; more runs may separate them.`}return null}function ze(e){const n=(h,k=1)=>h==null?"\u2014":h.toFixed(k);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")){Zs(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 a=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${a}-------|`);for(const h of e.rows){const k=s?` ${Gs[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${k} \u274C FAILED (${h.failureReason}) |`);continue}const b=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"no flags";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)} |${k} ${b} |`)}const i=fn(e.rows);if(i){r.push("");r.push(i)}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 k=h.topRenderers.map(y=>`${y.name} \xD7${y.renders} (${y.totalMs}ms${y.cause?`, ${y.cause}`:""})`).join(", ");const b=h.renderCommits!=null?`${h.renderCommits} commits`:"";const g=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[b,g,k].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 vt(e,n){const t=(r,s)=>({a:{avg:H(r.avg,1),p95:H(r.p95,1)},b:{avg:H(s.avg,1),p95:H(s.p95,1)},deltaAvg:H(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},...gn(e.renders,n.renders)}}function Xs(e,n){const t=(a,i,c)=>({a:a??null,b:i??null,delta:a!=null&&i!=null?H(i-a,1):null,better:c});const r=a=>a.webRun.heapSupported?H(a.stats.memoryUsage.max,1):null;const s=e.webRun;const o=n.webRun;return{a:e.name,b:n.name,platform:"web",note:"Browser runs: FPS is the main thread's whole-run requestAnimationFrame rate; long tasks % is not CPU. null = this browser couldn't measure it.",fps:t(s.fps??e.stats.jsFps.avg,o.fps??n.stats.jsFps.avg,"higher"),frameGapP95Ms:t(s.frameGapP95Ms,o.frameGapP95Ms,"lower"),frameGapMaxMs:t(s.frameGapMaxMs,o.frameGapMaxMs,"lower"),longFrames:t(s.longFrames,o.longFrames,"lower"),longTaskPct:t(s.longTaskPct,o.longTaskPct,"lower"),slowFrameBlockingMs:t(s.loaf?.blockingMs,o.loaf?.blockingMs,"lower"),styleLayoutPaintMs:t(s.loaf?.styleLayoutMs,o.loaf?.styleLayoutMs,"lower"),forcedLayoutMs:t(s.loaf?.forcedLayoutMs,o.loaf?.forcedLayoutMs,"lower"),worstInteractionMs:t(s.interactions?.worstMs,o.interactions?.worstMs,"lower"),layoutShift:t(s.layoutShift,o.layoutShift,"lower"),domNodes:t(s.domNodesEnd,o.domNodesEnd,"lower"),jsHeapMb:t(r(e),r(n),"lower"),topScripts:{a:s.loaf?.topScripts??[],b:o.loaf?.topScripts??[]},...gn(e.renders,n.renders)}}function gn(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 H(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function Zs(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(`**How to read this.** 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. For Chrome's own dropped-frame count, use run_web_benchmark.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | JS 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("; ")}`:"no flags";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; slow frames that ran past a run's end are left out):`);n.push(...s)}const o=Qs(e.rows);if(o.length){n.push("");n.push(...o)}const a=fn(e.rows);if(a){n.push("");n.push(a)}const i=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(i.length){n.push("");n.push(`**Not measured in this browser:** ${i.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.rows.some(l=>l.refreshCalibrated===false)){n.push("");n.push(`**Refresh rate not checked on an idle screen:** the batch couldn't reach its bounce screen first, so the screen rate (and frame budgets) may read low.`)}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 O}from"zod";import{z as eo}from"zod";var $=e=>({content:[{type:"text",text:e}]});var f=e=>({isError:true,content:[{type:"text",text:e}]});var M=e=>e instanceof Error?e.message:String(e);var A=eo.string().optional().describe("Target device id (auto-selected if one is connected).");function to(e,n){return typeof e==="number"&&e>0?e:n}function no(e){return e?`${e}
|
|
2
|
+
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await an(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 _s(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=Ts({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await Es(this.url);try{Is(this.broker.dashboardToken,mt(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=As(this.url,{transports:["websocket"],query:{deviceName:ht},auth:i=>{const c=this.broker?.dashboardToken??Ms(mt(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===Ds;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${mt(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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=ce(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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===Z){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()-Xr;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!==ht&&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(a=>a.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===Z){if(!this.connectedDevices().some(o=>o.isPro))throw new Error(t());return Z}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 on(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,n,t){const r=this.healthOf(e);const{timeoutMs:s,usedFullBudget:o}=Os({consecutiveTimeouts:r.consecutiveTimeouts,lastFullBudgetAt:r.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:Hr,recoveryProbeMs:Qt,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=Cs({consecutiveTimeouts:n.consecutiveTimeouts,lastOkAt:n.lastOkAt,now:Date.now(),unresponsiveAfterMs:Qr});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(ce(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(ce(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(ce(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,n){const t=ce(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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}unwatch(e,n){const t=ce(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(ce(n,e))}async getSnapshot(e,n,t={}){const r=this.resolveDevice(n);const s=ce(r,e);this.ensureWatching(e,r);let o=t.timeoutMs??Xt;if(t.timeoutMs===void 0&&this.isUnresponsive(r)){const u=this.healthOf(r);if(Date.now()-u.lastFullBudgetAt>Qt){u.lastFullBudgetAt=Date.now()}else{o=Yr}}const a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};r.push(o);this.snapshotWaiters.set(e,r)})}async callAction(e,n,t,r,s={}){this.requireAdmission();const o=r===Z?Z:this.resolveDevice(r);if(!this.socket)throw new Error("SyncClient is not connected");const a=xs();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const d=setTimeout(()=>{this.pendingActions.delete(a);if(o!==Z&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===Z?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:d,target:o});this.socket.emit("devtool-action",{requestId:a,toolId:e,targetDeviceId:o,action:n,params:t??{}})});if(o!==Z&&!s.silent)this.noteOk(o);if(!c.ok){throw new Error(c.error||`Action "${e}.${n}" failed on device`)}return c.result}};var ce=(e,n)=>`${e}::${n}`;function an(e,n=800){return new Promise(t=>{const r=Ss.get(e,s=>{s.resume();t((s.statusCode??0)>0)});r.setTimeout(n,()=>{r.destroy();t(false)});r.on("error",()=>t(false))})}async function Es(e,n=20){for(let t=0;t<n;t++){if(await an(e,300))return;await Bs(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var Bs=e=>new Promise(n=>setTimeout(n,e));var fe="7.0.52"?"7.0.52":"0.0.0-dev";var le="@buoy-gg/mcp";import{promises as cn}from"fs";import Ps from"os";import js from"path";var qs=24*60*60*1e3;var Ns=1500;var Ls=`https://registry.npmjs.org/${le}/latest`;var ln=js.join(Ps.tmpdir(),"buoy-mcp-update-check.json");function Us(e,n){const t=o=>o.split("-")[0].split(".").map(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)return false}return false}async function zs(){try{const e=await cn.readFile(ln,"utf8");const n=JSON.parse(e);if(typeof n.checkedAt==="number"&&typeof n.latest==="string"){return n}}catch{}return null}async function Ws(e){const n=new AbortController;const t=setTimeout(()=>n.abort(),Ns);try{const r=await fetch(Ls,{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 cn.writeFile(ln,JSON.stringify({checkedAt:e,latest:o})).catch(()=>{})}return o}catch{return null}finally{clearTimeout(t)}}async function Js(e){const n=await zs();if(n&&e-n.checkedAt<qs)return n.latest;return Ws(e)}async function un(e=Date.now()){if(fe==="0.0.0-dev")return null;try{const n=await Js(e);if(n&&Us(n,fe)){return`A newer buoy-mcp is available (${n}; you're on ${fe}). Update by re-running: npx -y ${le}@latest init \u2014 then restart your editor.`}}catch{}return null}import{z as ee}from"zod";function Re(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function ft(e){return{isError:true,content:[{type:"text",text:e}]}}function dn(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 un();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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return Re({devices:r,...i.length>0?{warning:`${i.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:a?"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:ee.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 ft(`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:ee.string().describe("The tool id to read state from."),deviceId:ee.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:ee.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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return Re({toolId:t,deviceId:o,snapshot:a})}catch(o){return ft(hn(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:ee.string().describe('The tool id, e.g. "perf-monitor".'),action:ee.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:ee.record(ee.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:ee.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:ee.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:r,params:s,deviceId:o,timeoutMs:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return Re({toolId:t,action:r,ok:true,result:c??null})}catch(i){return ft(hn(i))}})}function hn(e){return e instanceof Error?e.message:String(e)}var xe=(e,n,t)=>Math.max(n,Math.min(t,e));var pn="__batch_warmup__";var Gs={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var Ae={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function Te(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 k=(d.name||"").trim()||`case ${h+1}`;return{id:`case-${h}`,name:k,params:d.params??{},route:d.route}});const a=xe(Math.round(Te(e.runsPerCase,n.runsPerCase,Ae.runsPerCase)),1,10);const i=xe(Math.round(Te(e.coolDownMs,n.coolDownMs,Ae.coolDownMs)),0,3e4);const c=xe(Math.round(Te(e.discardWarmupRuns,n.discardWarmupRuns,Ae.discardWarmupRuns)),0,a-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(a===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:xe(Math.round(Te(e.perCaseDurationMs,n.perCaseDurationMs,Ae.perCaseDurationMs)),1e3,6e4),settleMs:xe(Math.round(Te(e.settleMs,n.settleMs,Ae.settleMs)),0,1e4),navTimeoutMs:xe(Math.round(Te(e.navTimeoutMs,n.navTimeoutMs,Ae.navTimeoutMs)),1e3,3e4),cases:o,runsPerCase:a,coolDownMs:i,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 mn(e){const n=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const r=gt(e);const s=r/6e4;const o=s>=1?`~${s.toFixed(1)} min`:`~${Math.round(r/1e3)}s`;const a=[`**${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 i=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${a.join(", ")}. That's ~${n(i)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${o}** total.`}function gt(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 Vs(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 Ks(e){const n=[];const t=e.deviceMaxRefreshRate;if(e.jsFps!=null&&t&&e.jsFps<t*.95){n.push("Low main-thread frame rate")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}const r=Math.max(e.frameGapMaxMs??0,e.edgeSilenceMs??0);if(r>=100)n.push(`Freeze of ${Math.round(r)} 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 grew every run (+${e.domGrowth} nodes): check for a leak`)}return n}function Hs(e){const n=e.filter(r=>!r.batchFailureReason&&!r.discardedWarmup).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 Ys(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 a=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,m)=>p-m);const i=p=>{const m=s.filter(_=>(_.batchIndex??0)===p);if(m.length===0)return void 0;const v=m.filter(_=>!_.batchFailureReason&&!_.discardedWarmup);const w=v.filter(_=>_.webRun?.fps!=null);if(w.length>0&&w.length===v.length){const _=[...w].sort((T,I)=>T.webRun.fps-I.webRun.fps);return _[Math.floor((_.length-1)/2)]}if(v.length>0){return v.find(_=>_.isMedianRun===true)??v.find(_=>_.runIndex===0||_.runIndex===void 0)??v[0]}return m.find(_=>_.batchFailureReason)??m[0]};const c=p=>typeof p==="number"?p:null;const u=a.map(p=>{const m=i(p);const v=s.filter(z=>(z.batchIndex??0)===p);const w=v.length;const _=v.filter(z=>!z.batchFailureReason&&!z.discardedWarmup);const T=z=>z.webRun?z.webRun.fps??z.jsFpsAvg:z.uiFpsAvg;const I=_.map(T).filter(z=>typeof z==="number");const F=m?.name??t?.[p]?.name??`case ${p+1}`;const lt=s.some(z=>(z.batchIndex??0)===p&&z.isBaseline===true);const C=o.get(p)??m?.batchFailureReason;const E=v.every(z=>z.batchFailureReason||z.discardedWarmup);const B=Boolean(C)||!m||E;const Q={batchIndex:p,caseName:F,params:m?.params??{},runs:w,failed:B,...m?.route?{route:m.route}:{},...lt?{isBaseline:true}:{},failureReason:B?C??(E&&m?"only warmup runs were saved":"no run recorded"):void 0,uiFps:c(m?.uiFpsAvg),jsFps:c(m?.jsFpsAvg),cpu:c(m?.cpuAvg),memMb:c(m?.memMaxMb),jsJank:c(m?.jsJankFrames),uiJank:c(m?.uiJankFrames),deviceMaxRefreshRate:c(m?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,validRuns:_.length,fpsMin:I.length?H(Math.min(...I),1):null,fpsMax:I.length?H(Math.max(...I),1):null,...m?.thermalState?{thermalState:m.thermalState}:{},renderCommits:c(m?.renderCommits),renderWasted:m?.renderWasted,topRenderers:m?.topRenderers??[]};const P=m?.webRun;if(!P)return{...Q,flags:B?[]:Vs(Q)};const Vt=[...P.longTasksSupported?[]:["long tasks"],...P.heapSupported?[]:["JS heap"],...P.loaf===null?["long animation frames"]:[]];const Kt={...Q,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:P.heapSupported?Q.memMb:null,deviceMaxRefreshRate:Math.max(Q.deviceMaxRefreshRate??0,P.refreshHz??0)||null,longTaskPct:P.longTaskPct,frameGapP95Ms:P.frameGapP95Ms,frameGapMaxMs:P.frameGapMaxMs,edgeSilenceMs:P.edgeSilenceMs??null,jsFps:P.fps??Q.jsFps,longFrames:P.longFrames,...Vt.length?{missingMetrics:Vt}:{},loafBlockingMs:P.loaf?.blockingMs??null,styleLayoutMs:P.loaf?.styleLayoutMs??null,forcedLayoutMs:P.loaf?.forcedLayoutMs??null,topScripts:P.loaf?.topScripts??[],worstInteractionMs:P.interactions?.worstMs??null,layoutShift:P.layoutShift??null,domNodes:P.domNodesEnd??null,domGrowth:Hs(v),pageLoad:P.pageLoad??null,refreshCalibrated:P.refreshCalibrated??null};return{...Kt,flags:B?[]:Ks({...Kt,frames:P.frames})}}).filter(p=>p.caseName!==pn).map((p,m)=>({...p,batchIndex:m}));const l=Ys(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=H(p.uiFps-l.uiFps,1);if(p.jsFps!=null&&l.jsFps!=null)p.deltaJsFps=H(p.jsFps-l.jsFps,1)}}const d=u.filter(p=>!p.failed);const h=(p,m)=>{let v;let w=NaN;for(const _ of d){const T=p(_);if(T==null)continue;if(v===void 0||m(T,w)){v=_;w=T}}return v?.caseName};const k=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const b={uiFps:h(p=>p.uiFps,(p,m)=>p>m),jsFps:h(p=>p.jsFps,(p,m)=>p>m),lowestCpu:h(p=>p.cpu,(p,m)=>p<m),lowestMem:h(p=>p.memMb,(p,m)=>p<m),fewestJank:h(k,(p,m)=>p<m),fewestLongTasks:h(p=>p.longTaskPct??null,(p,m)=>p<m)};const g=d.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const y=d.filter(p=>p.flags.length===0).sort((p,m)=>(m.uiFps??m.jsFps??0)-(p.uiFps??p.jsFps??0)||(p.cpu??p.longTaskPct??Infinity)-(m.cpu??m.longTaskPct??Infinity)||(p.memMb??Infinity)-(m.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:b,flagged:g,balanced:y}}function Qs(e){const n=e.filter(a=>!a.failed);if(n.length===0)return[];const t=n.map(a=>a.pageLoad?.navigationId);if(t.some(a=>a==null)||new Set(t).size!==t.length)return[];if(n.some(a=>(a.pageLoad?.pageAgeMs??Infinity)>3e4))return[];const r=a=>(a??"").replace(/\/+$/,"")||"/";const s=a=>{const i=a.pageLoad;if(!i?.loadPath||r(i.loadPath)!==r(a.route))return false;const c=new URLSearchParams(i.loadSearch??"");const u=[...c.keys()];if(new Set(u).size!==u.length)return false;const l=Object.entries(a.params);return u.length===l.length&&l.every(([d,h])=>c.get(d)===h)};if(n.some(a=>!s(a)))return[];const o=a=>a==null?"\u2014":String(Math.round(a));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(a=>{const i=a.pageLoad;return`| ${a.caseName} | ${o(i.ttfbMs)} | ${o(i.fcpMs)} | ${o(i.lcpMs)} | ${o(i.domContentLoadedMs)} | ${o(i.scriptKb)} | ${o(i.totalKb)} |`}),"",`_KB is bytes over the network. 0 can mean the file came from cache, so compare cold loads with cold loads._`]}function fn(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,a)=>(n(a)??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)||(n(r)??0)-(n(s)??0)<=2){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap or sit within 2 FPS. No clear winner between them; more runs may separate them.`}return null}function ze(e){const n=(h,k=1)=>h==null?"\u2014":h.toFixed(k);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")){Zs(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 a=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${a}-------|`);for(const h of e.rows){const k=s?` ${Gs[h.thermalState??"unknown"]??"\u2014"} |`:"";if(h.failed){r.push(`| ${h.batchIndex} | ${h.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${k} \u274C FAILED (${h.failureReason}) |`);continue}const b=h.flags.length?`\u26A0\uFE0F ${h.flags.join("; ")}`:"no flags";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)} |${k} ${b} |`)}const i=fn(e.rows);if(i){r.push("");r.push(i)}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 k=h.topRenderers.map(y=>`${y.name} \xD7${y.renders} (${y.totalMs}ms${y.cause?`, ${y.cause}`:""})`).join(", ");const b=h.renderCommits!=null?`${h.renderCommits} commits`:"";const g=h.renderWasted!=null?`${h.renderWasted} wasted`:"";return`- **${h.caseName}** \u2014 ${[b,g,k].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 vt(e,n){const t=(r,s)=>({a:{avg:H(r.avg,1),p95:H(r.p95,1)},b:{avg:H(s.avg,1),p95:H(s.p95,1)},deltaAvg:H(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},...gn(e.renders,n.renders)}}function Xs(e,n){const t=(a,i,c)=>({a:a??null,b:i??null,delta:a!=null&&i!=null?H(i-a,1):null,better:c});const r=a=>a.webRun.heapSupported?H(a.stats.memoryUsage.max,1):null;const s=e.webRun;const o=n.webRun;return{a:e.name,b:n.name,platform:"web",note:"Browser runs: FPS is the main thread's whole-run requestAnimationFrame rate; long tasks % is not CPU. null = this browser couldn't measure it.",fps:t(s.fps??e.stats.jsFps.avg,o.fps??n.stats.jsFps.avg,"higher"),frameGapP95Ms:t(s.frameGapP95Ms,o.frameGapP95Ms,"lower"),frameGapMaxMs:t(s.frameGapMaxMs,o.frameGapMaxMs,"lower"),longFrames:t(s.longFrames,o.longFrames,"lower"),longTaskPct:t(s.longTaskPct,o.longTaskPct,"lower"),slowFrameBlockingMs:t(s.loaf?.blockingMs,o.loaf?.blockingMs,"lower"),styleLayoutPaintMs:t(s.loaf?.styleLayoutMs,o.loaf?.styleLayoutMs,"lower"),forcedLayoutMs:t(s.loaf?.forcedLayoutMs,o.loaf?.forcedLayoutMs,"lower"),worstInteractionMs:t(s.interactions?.worstMs,o.interactions?.worstMs,"lower"),layoutShift:t(s.layoutShift,o.layoutShift,"lower"),domNodes:t(s.domNodesEnd,o.domNodesEnd,"lower"),jsHeapMb:t(r(e),r(n),"lower"),topScripts:{a:s.loaf?.topScripts??[],b:o.loaf?.topScripts??[]},...gn(e.renders,n.renders)}}function gn(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 H(e,n){const t=Math.pow(10,n);return Math.round(e*t)/t}function Zs(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(`**How to read this.** 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. For Chrome's own dropped-frame count, use run_web_benchmark.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | JS 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("; ")}`:"no flags";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; slow frames that ran past a run's end are left out):`);n.push(...s)}const o=Qs(e.rows);if(o.length){n.push("");n.push(...o)}const a=fn(e.rows);if(a){n.push("");n.push(a)}const i=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(i.length){n.push("");n.push(`**Not measured in this browser:** ${i.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.rows.some(l=>l.refreshCalibrated===false)){n.push("");n.push(`**Refresh rate not checked on an idle screen:** the batch couldn't reach its bounce screen first, so the screen rate (and frame budgets) may read low.`)}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 O}from"zod";import{z as eo}from"zod";var $=e=>({content:[{type:"text",text:e}]});var f=e=>({isError:true,content:[{type:"text",text:e}]});var M=e=>e instanceof Error?e.message:String(e);var A=eo.string().optional().describe("Target device id (auto-selected if one is connected).");function to(e,n){return typeof e==="number"&&e>0?e:n}function no(e){return e?`${e}
|
|
3
3
|
|
|
4
4
|
`:""}function vn(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 f(M(r))}}}var j="perf-monitor";var Ce=1500;var Fe=e=>new Promise(n=>setTimeout(n,e));var We=e=>({content:[{type:"text",text:JSON.stringify(e,null,2)}]});var ro=O.object({name:O.string().describe("Display name for this variant (becomes the saved run's name)."),params:O.record(O.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),route:O.string().optional().describe("Optional per-case route override; defaults to targetRoute.")});async function wt(e,n){try{const t=await e.callAction(j,"getAutomationConfig",{},n);if(!t)return{};const{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:d}}catch{return{}}}async function re(e,n){const{payload:t}=await e.getSnapshot(j,n,{timeoutMs:4e3});return t}function bt(e){const n=new Set;for(const t of e??[])if(t.batchId)n.add(t.batchId);return n}function wn(e,n){return(e??[]).filter(t=>t.batchId===n).length}function so(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 ge(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 oo(e){const n=e.cases.length+(e.discardWarmupCase===true?1:0);return n*(e.runsPerCase??1)}var ao=1e4;function io(e){return!!e&&e!=="idle"&&e!=="done"&&e!=="cancelled"}async function bn(e,n,t,r=ao){const s=Date.now();while(Date.now()-s<r){const o=await re(e,n).catch(()=>null);if(o&&!io(o.live?.automation?.phase))return true;await Fe(Ce)}t(`automation still busy after ${Math.round(r/1e3)}s`);return false}async function co(e,n,t){const{preBatchIds:r,expectedRuns:s,maxWaitMs:o,log:a}=t;const i=t.reloadsBetweenCases?10:3;const c=Date.now();let u="";let l=false;let d=0;while(Date.now()-c<o){let b;try{b=await re(e,n)}catch{await Fe(Ce);continue}const g=b.index??[];const y=b.live?.automation?.phase??"idle";const p=b.live?.automationCompleted;if(p?.phase==="cancelled"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:[],cancelled:true}}if(p?.phase==="done"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:p.failures??ge(g,p.batchId)}}if(y!=="idle")l=true;if(y!==u){u=y;a(`automation phase: ${y}`)}const m=[...bt(g)].find(v=>!r.has(v));if(m){const v=wn(g,m);if(s>0&&v>=s){await bn(e,n,a);a(`batch ${m} complete (${v}/${s} runs saved)`);return{batchId:m,failures:ge(g,m)}}const w=(g??[]).filter(F=>F.batchId===m&&F.name!==pn).length;const _=b.live?.pendingBatchId===m;const T=Boolean(b.live&&"pendingBatchId"in b.live);const I=!t.reloadsBetweenCases||T;if(y==="idle"&&l&&w>0&&!_&&I){if(++d>=i){a(`batch ${m} settled (${v} runs, automation idle)`);return{batchId:m,failures:ge(g,m)}}}else{d=0}}await Fe(Ce)}const h=await re(e,n).catch(()=>null);const k=h?[...bt(h.index)].find(b=>!r.has(b)):void 0;if(k&&h)return{batchId:k,failures:ge(h.index,k),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${u||"idle"}). Check get_automation_status \u2014 the device may not have navigated.`)}function yn(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:O.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),cases:O.array(ro).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),bounceRoute:O.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),runsPerCase:O.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),perCaseDurationMs:O.number().optional().describe("How long to record each run. Default 5000."),settleMs:O.number().optional().describe("Idle gap after navigation before recording. Default 600."),coolDownMs:O.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),navTimeoutMs:O.number().optional().describe("Max wait for navigation per run. Default 5000."),discardWarmupRuns:O.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),shuffleCases:O.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),reloadBetweenCases:O.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),captureRenders:O.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:O.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),maxWaitMs:O.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 wt(n,s);const{config:a,warnings:i}=Le(t,o);const c=gt(a);const u=t.maxWaitMs??c*3+6e4;const l=oo(a);if(!await bn(n,s,r)){return f("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(j,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(j,"setEnabled",{enabled:true},s).catch(()=>{});await n.callAction(j,"refreshIndex",{},s).catch(()=>{});const d=await re(n,s);const h=bt(d.index);r(`starting batch: ${a.cases.length} cases \xD7 ${a.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(j,"startAutomation",{config:a},s);const k=await co(n,s,{preBatchIds:h,expectedRuns:l,maxWaitMs:u,log:r,reloadsBetweenCases:a.reloadBetweenCases===true});if(k.cancelled){await n.callAction(j,"acknowledgeAutomation",{},s).catch(()=>{});return f(`Batch ${k.batchId} was cancelled before completing.`)}await n.callAction(j,"refreshIndex",{},s).catch(()=>{});const b=await re(n,s);const g=Ue(b.index??[],k.batchId,a.cases,k.failures);await n.callAction(j,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(j,"setEnabled",{enabled:false},s).catch(()=>{});if(g.rows.length===0){const m=n.connectedDevices().some(_=>_.deviceId===s);const v=b.live?.pendingBatchId===k.batchId;const w=!m?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:v?`The batch reloaded the app between cases and never resumed. Check that <AutomationResumer/> is mounted (the web host mounts it), or pass reloadBetweenCases: false.`:k.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 f(`Benchmark batch ${k.batchId||"(no id)"} saved no runs. ${w}`)}const y=ze(g);const p={batchId:k.batchId,leaders:g.leaders,balanced:g.balanced,flagged:g.flagged,rows:g.rows,failures:k.failures,warnings:i,estimateMs:c,...k.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return $(`${y}
|
package/dist/cli.js
CHANGED
|
@@ -142,7 +142,7 @@ _Tell the user to enter a licence in Buoy Desktop, or see https://buoy.gg/pricin
|
|
|
142
142
|
requestAnimationFrame(tick);
|
|
143
143
|
})`}});var webBench_exports={};__export(webBench_exports,{PLAYWRIGHT_MISSING:()=>PLAYWRIGHT_MISSING,aggregateCase:()=>aggregateCase,caseUrl:()=>caseUrl,cdpDeltas:()=>cdpDeltas,checkBudgets:()=>checkBudgets,closeCall:()=>closeCall,expectFailureFrom:()=>expectFailureFrom,formatWebBenchMarkdown:()=>formatWebBenchMarkdown,keyMatches:()=>keyMatches,locateStepTargets:()=>locateStepTargets,mergeInteractions:()=>mergeInteractions,missedInput:()=>missedInput,oneLine:()=>oneLine,percentile:()=>percentile,runWebBench:()=>runWebBench,shuffled:()=>shuffled,snapRefreshRate:()=>snapRefreshRate,summarizeFrameTrace:()=>summarizeFrameTrace,summarizeProbe:()=>summarizeProbe,takeHostLock:()=>takeHostLock,tiedPairs:()=>tiedPairs,validateWebBenchOptions:()=>validateWebBenchOptions,webBenchFlags:()=>webBenchFlags});function checkBudgets(e,n,t=[]){const r=e.median;const s=[];const o=[];const a=(h,d,m)=>{if(d==null)return;if(m==null)o.push(h);else if(m>d)s.push(`${h} ${m} > ${d}`)};a("droppedPct",n.droppedPct,e.droppedMin!=null?r?.droppedPct:null);const i=e.runs.filter(h=>!h.failed&&h.fps!=null);const c=t.some(h=>"click"in h||"press"in h||"type"in h);const u=i.map(h=>h.interactions==null||!h.inputCaptureComplete?null:h.worstInteractionMs??16);a("inputMs",n.inputMs,c&&u.length&&u.every(h=>h!=null)?Math.max(...u):null);const l=i.map(h=>Math.max(h.frameGapMaxMs??0,h.edgeSilenceMs??0));a("freezeMs",n.freezeMs,l.length?Math.max(...l):null);a("mainThreadPct",n.mainThreadPct,r?.mainThreadPct);a("jsHeapMb",n.jsHeapMb,r?.heapMb);return{meets:Boolean(r)&&s.length===0&&o.length===0,over:s,unknown:o}}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 a=Math.floor(r()*(o+1));[s[o],s[a]]=[s[a],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,a]of Object.entries(n.params??{}))r.searchParams.set(o,a);return r.toString()}function summarizeFrameTrace(e){const n=m=>e.find(w=>w.name===m&&w.ts!=null);const t=n("__buoy_bench_start");const r=n("__buoy_bench_end");if(!t||!r||r.ts<=t.ts)return null;if(t.pid!==r.pid)return null;const s=t.pid;const o={STATE_PRESENTED_ALL:0,STATE_PRESENTED_PARTIAL:1,STATE_DROPPED:2};const a=new Map;for(const m of e){if(m.name!=="PipelineReporter"||m.ph!=="b")continue;if(s!=null&&m.pid!==s)continue;if(m.ts==null||m.ts<t.ts||m.ts>r.ts)continue;const w=m.args?.frame_reporter;if(!w||w.frame_sequence==null)continue;const g=w.state;if(!FRAME_STATES.includes(g))continue;const b=`${w.layer_tree_host_id??""}:${w.frame_source??""}:${w.frame_sequence}`;const p=Boolean(w.affects_smoothness)&&g!=="STATE_PRESENTED_ALL";const f=a.get(b);if(!f)a.set(b,{state:g,fault:p});else{if(o[g]>o[f.state])f.state=g;f.fault=f.fault||p}}let i=0;let c=0;let u=0;let l=0;for(const m of a.values()){if(m.state==="STATE_PRESENTED_ALL")i++;else if(m.state==="STATE_PRESENTED_PARTIAL")c++;else u++;if(m.fault)l++}const h=i+c+u;if(h===0)return null;const d=(r.ts-t.ts)/1e6;return{full:i,partial:c,dropped:u,faults:l,shownFps:r1((i+c)/d),droppedPct:r1(l/h*100)}}function keyMatches(e,n){const t=e.split("+");const r=t[t.length-1];const s=new Set(t.slice(0,-1).map(i=>i.toLowerCase()));if(n.key!==r)return false;const o=r.toLowerCase();const a={ctrl:s.has("control")||o==="control",alt:s.has("alt")||o==="alt",meta:s.has("meta")||s.has("command")||o==="meta",shift:s.has("shift")||o==="shift"};if(Boolean(n.ctrl)!==a.ctrl||Boolean(n.alt)!==a.alt||Boolean(n.meta)!==a.meta)return false;if(!a.shift&&n.shift&&r.length!==1)return false;if(a.shift&&!n.shift)return false;return true}function mergeInteractions(e){const n=[];for(const t of e){const r=t.startTime+t.duration;const s=n.find(a=>a.interactionId===t.interactionId&&Math.abs(a.startTime+a.duration-r)<=8);if(!s){n.push({...t});continue}const o=s.startTime+s.duration;s.startTime=Math.min(s.startTime,t.startTime);s.processingStart=Math.min(s.processingStart,t.processingStart);s.processingEnd=Math.max(s.processingEnd,t.processingEnd);s.duration=Math.max(o,r)-s.startTime;if(t.processingEnd-t.processingStart>0)s.name=t.name}return n}function summarizeProbe(e,n){const{start:t,end:r}=e;const s=Math.max(1,r-t);const o=[];for(let _=1;_<e.frames.length;_++)o.push(e.frames[_]-e.frames[_-1]);const a=1e3/n;const i=percentile(o,95);let c=null;if(e.longTasks){let _=0;for(const M of e.longTasks){const B=Math.min(M.end,r)-Math.max(M.start,t);if(B>0)_+=B}c=r1(Math.min(100,_/s*100))}let u=null;let l=null;let h=null;let d=[];let m=0;if(e.loaf){let _=0;let M=0;let B=0;const I=new Map;for(const x of e.loaf){if(x.startTime>=t&&x.startTime<=r&&x.startTime+x.duration>r)m++;if(x.startTime<t||x.startTime+x.duration>r)continue;_+=x.blockingDuration;if(x.styleAndLayoutStart>x.startTime)M+=Math.max(0,x.startTime+x.duration-x.styleAndLayoutStart);for(const $ of x.scripts){B+=$.forced;const T=I.get($.name)??{ms:0,count:0};T.ms+=$.duration;T.count++;I.set($.name,T)}}u=r1(_);l=r1(M);h=r1(B);d=[...I.entries()].map(([x,$])=>({name:x,ms:r1($.ms),count:$.count})).sort((x,$)=>$.ms-x.ms).slice(0,5)}let w=null;if(e.shifts){let _=0;for(const M of e.shifts)if(!M.hadRecentInput&&M.startTime>=t&&M.startTime<=r)_+=M.value;w=Math.round(_*1e3)/1e3}let g=null;let b=null;if(e.events){const _=mergeInteractions(e.events.filter(M=>M.startTime>=t&&M.startTime<=r));g=new Set(_.map(M=>M.interactionId)).size;for(const M of _)if(!b||M.duration>b.duration)b=M}const p=b;const f=e.frames;const v=[...(e.clicks??[]).map(_=>_.t),...(e.keys??[]).map(_=>_.t)];const y=v.length?Math.max(...v):null;const k=y==null||f.filter(_=>_>y).length>=2;const S=f.length===0?s:Math.max(0,f[0]-t,r-f[f.length-1]);return{edgeSilenceMs:r1(S),frameGapCount:o.length,inputCaptureComplete:k,resized:Boolean(e.resized),interactions:g,worstInteractionMs:p?r1(p.duration):null,worstInputDelayMs:p?r1(Math.max(0,p.processingStart-p.startTime)):null,worstProcessingMs:p?r1(Math.max(0,p.processingEnd-p.processingStart)):null,worstPresentationMs:p?r1(Math.max(0,p.startTime+p.duration-p.processingEnd)):null,fps:r1(e.frames.length/s*1e3),frameGapP95Ms:i==null?null:r1(i),frameGapMaxMs:o.length?r1(Math.max(...o)):null,longFrames:o.filter(_=>_>a*1.5).length,longTaskPct:c,loafBlockingMs:u,styleLayoutMs:l,forcedLayoutMs:h,topScripts:d,loafCutOff:m,layoutShift:w,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 c=r(i);return c==null?null:r1(c*1e3)};const o=s("TaskDuration");const a=s("Timestamp")??t;return{mainThreadPct:o==null?null:r1(o/Math.max(1,a)*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){const t=[];if(e.droppedPct!=null&&e.droppedPct>=5)t.push(`${e.droppedPct}% of frames dropped or partial`);if(e.fps!=null&&e.fps<n*.95)t.push("Low main-thread frame rate");if(e.frameGapCount&&e.longFrames/e.frameGapCount>=.02)t.push("Frequent long frames");const r=Math.max(e.frameGapMaxMs??0,e.edgeSilenceMs??0);if(r>=100)t.push(`Freeze of ${Math.round(r)} ms`);if(e.mainThreadPct!=null&&e.mainThreadPct>=80)t.push("Main thread near full");if(e.longTaskPct!=null&&e.longTaskPct>=20)t.push("Long tasks block the main thread");if(e.forcedLayoutMs!=null&&e.forcedLayoutMs>=50)t.push(`Forced layout in scripts (${Math.round(e.forcedLayoutMs)} ms)`);if(e.layoutShift!=null&&e.layoutShift>=.1)t.push(`Layout shifts (${e.layoutShift})`);if(e.worstInteractionMs!=null&&e.worstInteractionMs>=200){t.push(`Slow response to input (${Math.round(e.worstInteractionMs)} ms)`)}return t}function aggregateCase(e,n){const t=e.filter(c=>!c.failed&&c.fps!=null);const r=[...t].sort((c,u)=>(c.fps??0)-(u.fps??0));const s=t.filter(c=>c.droppedPct!=null).length;const o=t.length>0&&s===t.length;const a=o?[...t].sort((c,u)=>c.droppedPct-u.droppedPct):r;const i=a.length?a[Math.floor((a.length-1)/2)]:null;return{caseName:e[0]?.caseName??"",cpu:e[0]?.cpu??1,validRuns:t.length,plannedRuns:e.length,isBaseline:false,deltaDroppedPct:null,tracedRuns:s,...(()=>{const c=o?t.map(l=>l.droppedPct):[];const u=t.map(l=>l.worstInteractionMs).filter(l=>l!=null);return{droppedMin:c.length?Math.min(...c):null,droppedMax:c.length?Math.max(...c):null,inputMin:u.length?Math.min(...u):null,inputMax:u.length?Math.max(...u):null}})(),runs:e,median:i,fpsMin:r.length?r[0].fps:null,fpsMax:r.length?r[r.length-1].fps:null,flags:i?webBenchFlags(o?i:{...i,droppedPct:null},n):[]}}async function loadChromium(e){const n=(0,import_node_module.createRequire)((0,import_node_path5.join)(e,"noop.js"));for(const t of["playwright","playwright-core"]){let r;try{r=n.resolve(t)}catch{continue}const s=await import((0,import_node_url.pathToFileURL)(r).href);const o=s.chromium??s.default?.chromium;if(o)return o}throw new Error(`${PLAYWRIGHT_MISSING}
|
|
144
144
|
(Searched from ${e}. Pass projectRoot to look elsewhere.)`)}async function withDeadline(e,n,t){let r;const s=new Promise(o=>{r=setTimeout(()=>o("timeout"),n)});try{const o=await Promise.race([e,s]);if(o==="timeout"){e.catch(()=>{});return await t()}return o}finally{clearTimeout(r)}}function gitCommit(e){try{return(0,import_node_child_process6.execFileSync)("git",["rev-parse","HEAD"],{cwd:e,stdio:["ignore","pipe","ignore"],timeout:3e3}).toString().trim()}catch{return null}}function gitDirty(e){try{const n=(0,import_node_child_process6.execFileSync)("git",["status","--porcelain"],{cwd:e,stdio:["ignore","pipe","ignore"],timeout:5e3});return n.toString().trim().length>0}catch{return null}}async function closeWithin(e,n){if(!e)return true;return withDeadline(e.close().then(()=>true,()=>false),n,async()=>false)}function validateWebBenchOptions(e){if(!e.cases?.length)throw new Error("Give at least one case.");const n=new Set;for(const a of e.cases){if(!a.name?.trim())throw new Error("Every case needs a name.");if(n.has(a.name))throw new Error(`Two cases are named "${a.name}". Give each case its own name.`);n.add(a.name)}const t=(a,i,c,u)=>{if(a==null)return;if(!Number.isInteger(a)||a<c||a>u)throw new Error(`${i} must be a whole number from ${c} to ${u}.`)};const r=(a,i,c)=>{if(a==null)return;if(!Number.isFinite(a)||a<c)throw new Error(`${i} must be a number of at least ${c}.`)};const s=(a,i,c,u)=>{if(a==null)return;if(!Number.isFinite(a)||a<c||a>u)throw new Error(`${i} must be from ${c} to ${u}.`)};t(e.runsPerCase,"runsPerCase",1,10);s(e.durationMs,"durationMs",500,12e4);s(e.settleMs,"settleMs",0,6e4);s(e.refreshHz,"refreshHz",1,500);const o=e.cpuSlowdown??[];for(const a of o)s(a,"each cpuSlowdown rate",1,20);if(new Set(o).size!==o.length)throw new Error("cpuSlowdown has the same rate twice.");if(e.viewport){t(e.viewport.width,"viewport.width",200,8e3);t(e.viewport.height,"viewport.height",200,8e3);s(e.viewport.deviceScaleFactor,"viewport.deviceScaleFactor",.5,4)}t(e.extraRoundsWhenClose,"extraRoundsWhenClose",0,10);s(e.maxBatchMs,"maxBatchMs",1e4,6*60*6e4);for(const[a,i]of Object.entries(e.budgets??{}))s(i,`budgets.${a}`,0,1e9);for(const a of e.steps??[]){if("wait"in a)s(a.wait,"a wait step",0,6e4);if("scroll"in a)s(a.scroll,"a scroll step",-1e5,1e5)}new URL(e.url)}function oneLine(e){return e.replace(/\u001b\[[0-9;]*m/g,"").split("\n")[0].trim().slice(0,160)}async function locateStepTargets(e,n){const t=new Map;for(const r of n){if("expect"in r)continue;const s="click"in r?r.click:"type"in r?r.type.selector:null;if(!s||t.has(s))continue;const o=await e.$(s);const a=o?await o.boundingBox():null;if(!a)throw new Error(`step target ${s} isn't on the page (or isn't visible)`);t.set(s,{x:a.x+a.width/2,y:a.y+a.height/2})}return t}async function runSteps(e,n,t){for(const r of n){if("click"in r){const s=t.get(r.click);await e.mouse.click(s.x,s.y)}else if("press"in r)await e.keyboard.press(r.press);else if("type"in r){const s=t.get(r.type.selector);await e.mouse.click(s.x,s.y);await e.keyboard.type(r.type.text)}else if("scroll"in r)await e.mouse.wheel(0,r.scroll);else if("wait"in r)await sleep2(r.wait)}}function missedInput(e,n,t=[]){const r=[...n].sort((l,h)=>l.t-h.t);const s=[...t].sort((l,h)=>l.t-h.t);let o=0;let a=0;let i=-Infinity;const c=l=>{while(o<r.length){const h=r[o++];if(h.t>=i&&h.hit.includes(l)){i=h.t;return true}}return false};const u=l=>{while(a<s.length){const h=s[a++];if(h.t>=i&&keyMatches(l,h)){i=h.t;return true}}return false};for(const[l,h]of e.entries()){if("click"in h&&!c(h.click)){return`step ${l+1} (click ${h.click}) has no real click on its target, in order (moved, covered, or ignored)`}if("type"in h){if(!c(h.type.selector))return`step ${l+1} (type into ${h.type.selector}) has no real click on its target`;for(const d of h.type.text){if(!u(d))return`step ${l+1} (type "${h.type.text}") didn't deliver the key "${d}"`}}if("press"in h&&!u(h.press))return`step ${l+1} (press ${h.press}) didn't deliver that key`}return null}async function expectsAlreadyTrue(e,n){for(const t of n){if(!("expect"in t))continue;const{selector:r,text:s}=t.expect;const o=await e.evaluate(`(() => { const n = document.querySelector(${JSON.stringify(r)}); if (!n) return false; ${s==null?"return true;":`return (n.textContent || "").includes(${JSON.stringify(s)});`} })()`);if(o)return s==null?r:`${r} contains "${s}"`}return null}async function checkTargetsStill(e,n){for(const[t,r]of n){const s=await e.$(t);const o=s?await s.boundingBox():null;if(!o)return`step target ${t} was gone after the run`;const a=o.x+o.width/2;const i=o.y+o.height/2;if(Math.abs(a-r.x)>4||Math.abs(i-r.y)>4){return`step target ${t} moved during the run, so a click may have missed it`}}return null}function expectFailureFrom(e){for(const n of e){if(!n.found)return`expected ${n.selector} at the end of the run, but it isn't there`;const t=n.matched??(n.text==null||(n.content??"").includes(n.text));if(!t){return`expected ${n.selector} to contain "${n.text}" at the end of the run, got "${(n.content??"").slice(0,60)}"`}}return null}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=()=>{}){validateWebBenchOptions(e);const t=takeHostLock();const r={browserLeftRunning:false};try{return await runWebBenchLocked(e,n,r)}finally{t(r.browserLeftRunning)}}function takeHostLock(){if(lockedHere){throw new Error("A web benchmark is already running in this process (or left a stuck browser). Wait, or restart the MCP server.")}const e=`${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;const n=JSON.stringify({pid:process.pid,token:e,startedAt:new Date().toISOString()});const t=()=>{const o=`${LOCK_FILE}.${e.replace(/[^\w]/g,"")}.tmp`;try{(0,import_node_fs5.writeFileSync)(o,n);(0,import_node_fs5.linkSync)(o,LOCK_FILE);return true}catch{return false}finally{try{(0,import_node_fs5.unlinkSync)(o)}catch{}}};const r=()=>{try{return JSON.parse((0,import_node_fs5.readFileSync)(LOCK_FILE,"utf8"))}catch{return null}};const s=o=>{if(!o)return false;try{process.kill(o,0);return true}catch{return false}};if(!t()){const o=r();if(!o)throw new Error("Another web benchmark is starting. Wait for it to finish.");if(s(o.pid)){throw new Error(`Another web benchmark is running (pid ${o.pid}, since ${o.startedAt}). Two at once would skew both; wait for it to finish.`)}const a=`${LOCK_FILE}.cleanup`;let i=null;try{if(Date.now()-(0,import_node_fs5.statSync)(a).mtimeMs>3e4){const c=`${a}.${e.replace(/[^\w]/g,"")}.old`;(0,import_node_fs5.renameSync)(a,c);if(Date.now()-(0,import_node_fs5.statSync)(c).mtimeMs>3e4)(0,import_node_fs5.unlinkSync)(c);else{try{(0,import_node_fs5.linkSync)(c,a)}catch{}(0,import_node_fs5.unlinkSync)(c)}}}catch{}try{i=(0,import_node_fs5.openSync)(a,"wx")}catch{throw new Error("Another web benchmark is clearing an old lock. Try again in a moment.")}try{const c=r();if(c?.token===o.token)(0,import_node_fs5.unlinkSync)(LOCK_FILE);if(!t())throw new Error("Another web benchmark just started. Wait for it to finish.")}finally{(0,import_node_fs5.closeSync)(i);try{(0,import_node_fs5.unlinkSync)(a)}catch{}}}lockedHere=true;return(o=false)=>{if(o)return;lockedHere=false;if(r()?.token===e){try{(0,import_node_fs5.unlinkSync)(LOCK_FILE)}catch{}}}}async function runWebBenchLocked(e,n,t){const r=e.runsPerCase??3;const s=Math.max(500,e.durationMs??5e3);const o=Math.max(0,e.settleMs??1e3);const a=e.frameTrace!==false;const i=(e.cpuSlowdown?.length?e.cpuSlowdown:[1]).map($=>Math.max(1,$));let c=e.refreshHz??60;const u={width:e.viewport?.width??1280,height:e.viewport?.height??800,deviceScaleFactor:e.viewport?.deviceScaleFactor??1};const l=[];const h=[];if(r<2)l.push("runsPerCase=1 gives no spread; use 3 or more before choosing.");const d=await loadChromium(e.projectRoot??process.cwd());const m=await d.launch({headless:!e.headed});const w=[];const g=[];const b=Date.now();const p=new Date(b).toISOString();const f=e.maxBatchMs??30*6e4;let v=false;let y=false;const k={commit:gitCommit(e.projectRoot??process.cwd()),dirty:gitDirty(e.projectRoot??process.cwd())};try{const $={viewport:{width:u.width,height:u.height},deviceScaleFactor:u.deviceScaleFactor};const T=await withDeadline(m.newContext($),3e4,async()=>{t.browserLeftRunning=true;throw new Error("Chromium didn't open a page in 30 s. Restart the MCP server and try again.")});const A=3e4+15e3+o+s+6e4;const U=async(E,R,C,O)=>{const F={};return withDeadline(D(F,E,R,C,O),A,async()=>{F.cancelled=true;if(!F.ctx)v=true;else if(!await closeWithin(F.ctx,1e4))v=true;return{...N(E.name,R,C),failed:`run took over ${Math.round(A/1e3)} s (the page may be stuck in a loop)`}})};const N=(E,R,C)=>({caseName:E,cpu:R,round:C,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:[]});const D=async(E,R,C,O,F)=>{const H=await m.newContext($);E.ctx=H;if(E.cancelled){await closeWithin(H,1e4);throw new Error("run was cancelled during setup")}const z=await H.newPage();const W=[];z.on("pageerror",P=>W.push(String(P?.message??P)));const q={...N(R.name,C,O),pageErrors:W};try{const P=await H.newCDPSession(z);await P.send("Performance.enable",{timeDomain:"timeTicks"});if(C>1)await P.send("Emulation.setCPUThrottlingRate",{rate:C});const Z=await z.goto(caseUrl(e.url,R,e.path),{waitUntil:"load",timeout:3e4});if(Z&&Z.status()>=400)return{...q,failed:`HTTP ${Z.status()}`};if(e.readySelector)await z.waitForSelector(e.readySelector,{state:"attached",timeout:15e3});await sleep2(o);const se=F||a;if(se){await P.send("Tracing.start",{transferMode:"ReturnAsStream",categories:(F?FULL_TRACE_CATEGORIES:FRAME_TRACE_CATEGORIES).join(",")})}const ee=e.steps?.length?await locateStepTargets(z,e.steps):new Map;if(e.steps?.length){const K=await expectsAlreadyTrue(z,e.steps);if(K)return{...q,failed:`expect ${K} already held before the steps, so it can't prove they worked`};const Y=[...ee.keys()];const G=e.steps.flatMap(Q=>"expect"in Q?[Q.expect]:[]);await z.evaluate(`window.__buoyBenchClickTargets = ${JSON.stringify(Y)}; window.__buoyBenchExpects = ${JSON.stringify(G)}; true`)}const te=await readMetrics(P);await z.evaluate(START_PROBE_JS);const oe=Date.now();if(e.steps?.length)await runSteps(z,e.steps,ee);const ae=s-(Date.now()-oe);if(ae>0)await sleep2(ae);await z.evaluate(END_PROBE_JS);const ne=await readMetrics(P);const ie=Date.now()-oe;const J=await z.evaluate(COLLECT_PROBE_JS);let j=null;if(se){const K=new Promise(L=>P.on("Tracing.tracingComplete",X=>L(X.stream)));await P.send("Tracing.end");const Y=await K;const G=F?(0,import_node_fs5.openSync)(F,"w"):null;const Q=[];let re=0;try{for(;;){const L=await P.send("IO.read",{handle:Y});const X=L.base64Encoded?Buffer.from(L.data,"base64").toString("utf8"):L.data;re+=X.length;if(G!=null)(0,import_node_fs5.writeSync)(G,X);if(re<=MAX_TRACE_CHARS)Q.push(X);else Q.length=0;if(L.eof)break}}finally{await P.send("IO.close",{handle:Y}).catch(()=>{});if(G!=null)(0,import_node_fs5.closeSync)(G)}if(F)h.push(F);if(re>MAX_TRACE_CHARS){l.push(`Trace for ${R.name} was over ${Math.round(MAX_TRACE_CHARS/1e6)} MB, so its frames weren't counted. A page that busy shows its problem in the table already.`)}else{try{const L=JSON.parse(Q.join(""));j=summarizeFrameTrace(Array.isArray(L)?L:L.traceEvents??[])}catch{j=null}}}if(!J)return{...q,failed:"probe returned nothing (page navigated away?)"};if(J.hidden)return{...q,failed:"page was hidden during the run"};if(J.resized)return{...q,failed:"the window size or zoom changed during the run"};if(W.length&&!e.allowPageErrors){return{...q,failed:`page error: ${oneLine(W[0])}`}}if(e.steps?.length){const K=missedInput(e.steps,J.clicks??[],J.keys??[]);if(K)return{...q,failed:K};const Y=expectFailureFrom(J.expectResults??[]);if(Y)return{...q,failed:Y};const G=await checkTargetsStill(z,ee);if(G)return{...q,failed:G}}const ce="Timestamp"in te&&"Timestamp"in ne?r1((ne.Timestamp-te.Timestamp)*1e3):null;return{...q,...summarizeProbe(J,c),...cdpDeltas(te,ne,ie),probeSpanMs:r1(J.end-J.start),cdpSpanMs:ce,shownFps:j?.shownFps??null,droppedFrames:j?.faults??null,frameCounts:j?{full:j.full,partial:j.partial,dropped:j.dropped,faults:j.faults}:null,droppedPct:j?.droppedPct??null}}catch(P){return{...q,failed:oneLine(P?.message??String(P))}}finally{await closeWithin(z,5e3);if(!await closeWithin(H,1e4))v=true}};if(e.refreshHz==null){const E=await withDeadline(T.newPage(),15e3,async()=>{throw new Error("Chromium didn't open the calibration page in 15 s.")});try{await withDeadline(E.goto("about:blank"),15e3,async()=>null);const R=await withDeadline(E.evaluate(MEASURE_REFRESH_JS),5e3,async()=>{throw new Error("idle rate check timed out")});c=snapRefreshRate(R);n(`idle frame rate ${R.toFixed(1)} \u2192 ${c} Hz`)}catch{l.push("Couldn't measure the idle frame rate; frame budgets assume 60 Hz.")}finally{await closeWithin(E,5e3)}}n("warmup load");await U(e.cases[0],1,-1);const V=async(E,R,C,O)=>{if(v){w.push({...N(E.name,R,C),failed:"an earlier run left the browser stuck; batch stopped"});return}if(Date.now()-b>f){w.push({...N(E.name,R,C),failed:"batch time limit reached; run skipped"});return}n(O);g.push({cpu:R,round:C,caseName:E.name});w.push(await U(E,R,C))};for(const E of i){for(let R=0;R<r;R++){const C=shuffled(e.cases,2654435769^R*7919+E);for(const O of C)await V(O,E,R,`cpu ${E}x \xB7 round ${R+1}/${r} \xB7 ${O.name}`)}for(let R=0;R<(e.extraRoundsWhenClose??0);R++){const C=e.cases.map(z=>aggregateCase(w.filter(W=>W.cpu===E&&W.caseName===z.name),c));for(const z of C)z.control=e.cases.find(W=>W.name===z.caseName)?.control===true;const O=new Set([...tiedPairs(C).flat(),...C.filter(z=>!z.control&&z.validRuns<2&&z.plannedRuns>0).map(z=>z.caseName)]);if(O.size===0)break;const F=r+R;const H=shuffled(e.cases.filter(z=>O.has(z.name)),20973^F*7919+E);for(const z of H)await V(z,E,F,`cpu ${E}x \xB7 extra round ${R+1} (close call) \xB7 ${z.name}`)}}if(e.traceDir){(0,import_node_fs5.mkdirSync)(e.traceDir,{recursive:true});for(const[E,R]of e.cases.entries()){const C=(0,import_node_path5.join)(e.traceDir,`${String(E+1).padStart(2,"0")}-${R.name.replace(/[^\w.-]+/g,"_")}.json`);if(v||Date.now()-b>f){l.push(`Trace run for ${R.name} skipped (batch time limit or a stuck browser).`);continue}n(`trace \xB7 ${R.name}`);const O=await U(R,i[0],-1,C);if(O.failed)l.push(`Trace run for ${R.name} failed: ${O.failed}`)}}await closeWithin(T,1e4)}finally{const $=await closeWithin(m,15e3);t.browserLeftRunning=!$;if(!$){y=true;l.push("Chromium didn't close in 15 s and may still be running. The benchmark lock stays held; restart the MCP server before the next batch.")}}const S=[];for(const $ of i){for(const T of e.cases){S.push(aggregateCase(w.filter(A=>A.cpu===$&&A.caseName===T.name),c))}}for(const $ of S)$.control=e.cases.find(T=>T.name===$.caseName)?.control===true;for(const $ of i){const T=S.filter(D=>D.cpu===$);const A=T.find(D=>!D.control);if(!A)continue;A.isBaseline=true;const U=D=>D.droppedMin!=null?D.median?.droppedPct??null:null;const N=U(A);for(const D of T.filter(V=>V!==A&&!V.control)){const V=U(D);D.deltaDroppedPct=N!=null&&V!=null?r1(V-N):null}}if(e.allowPageErrors&&w.some($=>$.pageErrors.length)){l.push("Some runs threw page errors (kept because allowPageErrors is on).")}if(v)l.push("A browser context wouldn't close, so the batch stopped early. Restart and run again.");if(e.budgets)for(const $ of S)$.budget=checkBudgets($,e.budgets,e.steps??[]);const _=S.filter($=>$.median).length;const M=S.every($=>$.validRuns===$.plannedRuns);const B=_===0?"failed":_<S.length||!M?"partial":"ok";const I=S.filter($=>$.median&&$.validRuns>0&&$.validRuns<$.plannedRuns);for(const $ of I){const T=Array.from(new Set($.runs.filter(A=>A.failed).map(A=>A.failed)));l.push(`${$.caseName} (CPU ${$.cpu}x): ${$.plannedRuns-$.validRuns} of ${$.plannedRuns} runs failed: ${T.join("; ")}`)}for(const $ of S){const T=$.median;if(T?.probeSpanMs&&T.cdpSpanMs&&Math.abs(T.cdpSpanMs-T.probeSpanMs)/T.probeSpanMs>.15){l.push(`${$.caseName} (CPU ${$.cpu}x): Chrome's counters cover ${Math.round(T.cdpSpanMs)} ms but frames cover ${Math.round(T.probeSpanMs)} ms, so its cost columns include extra time.`)}if(T?.probeSpanMs!=null&&T.probeSpanMs<s*.8){l.push(`${$.caseName} (CPU ${$.cpu}x) recorded ${Math.round(T.probeSpanMs)} ms, not the ${s} ms asked for.`)}}const x={status:B,hasSteps:Boolean(e.steps?.length),browserLeftRunning:y,replay:{startedAt:p,...k,runOrder:g},url:e.url,browser:`Chromium ${m.version()}${e.headed?"":" (headless)"}`,viewport:u,refreshHz:c,durationMs:s,runsPerCase:r,results:S,traces:h,warnings:l};if(e.outFile){try{(0,import_node_fs5.writeFileSync)(e.outFile,JSON.stringify({options:e,result:x},null,2))}catch($){x.warnings.push(`Couldn't write outFile ${e.outFile}: ${oneLine($?.message??String($))}`)}}return x}function tiedPairs(e){const n=e.filter(o=>!o.control&&o.median&&o.validRuns>1);const t=n.filter(o=>o.droppedMin!=null);const r=[];if(t.length>=2&&t.length===n.length){const o=[...t].sort((a,i)=>a.median.droppedPct-i.median.droppedPct);for(let a=1;a<o.length;a++){const[i,c]=[o[a-1],o[a]];const u=c.median.droppedPct-i.median.droppedPct<=MIN_DROPPED_GAP_PP;if(c.droppedMin<=i.droppedMax||u)r.push([i.caseName,c.caseName])}return r}if(t.length>0)return r;const s=n.filter(o=>o.median.fps!=null).sort((o,a)=>a.median.fps-o.median.fps);for(let o=1;o<s.length;o++){const[a,i]=[s[o-1],s[o]];const c=a.median.fps-i.median.fps<=MIN_FPS_GAP;if((i.fpsMax??0)>=(a.fpsMin??0)||c)r.push([a.caseName,i.caseName])}return r}function closeCall(e){const n=e.filter(i=>!i.control&&i.median);const t=[];const r=n.filter(i=>i.validRuns<2);if(r.length)t.push(`**Too few runs to rank:** ${r.map(i=>i.caseName).join(", ")} (need 2 or more valid runs).`);const s=n.filter(i=>i.validRuns>1);const o=s.filter(i=>i.droppedMin!=null);if(o.length>0&&o.length<s.length){t.push(`**Not enough traced runs to compare smoothness:** ${s.filter(i=>!o.includes(i)).map(i=>`${i.caseName} (${i.tracedRuns}/${i.validRuns} traced)`).join(", ")}.`);return t.join("\n")}const a=new Map(s.map(i=>[i.caseName,i]));for(const[i,c]of tiedPairs(e)){const u=a.get(i);const l=a.get(c);t.push(o.length?`**Too close to call:** ${i} (${u.droppedMin}\u2013${u.droppedMax}% dropped) and ${c} (${l.droppedMin}\u2013${l.droppedMax}%) overlap or sit within ${MIN_DROPPED_GAP_PP} pp. No clear winner between them; more runs may separate them.`:`**Too close to call:** ${i} and ${c} overlap or sit within ${MIN_FPS_GAP} FPS. No clear winner between them; more runs may separate them.`)}return t.length?t.join("\n"):null}function formatWebBenchMarkdown(e){const n=(i,c=1)=>i==null?"\u2014":i.toFixed(c);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${e.results.some(i=>i.plannedRuns>e.runsPerCase)?" (more for close calls)":""} \xD7 ${e.durationMs/1e3}s per case, fresh browser context each run, case order shuffled each round.`);const r=e.results.map(i=>i.median?.probeSpanMs).filter(i=>i!=null);const s=e.results.map(i=>i.median?.cdpSpanMs).filter(i=>i!=null);const o=i=>`${(Math.min(...i)/1e3).toFixed(2)}\u2013${(Math.max(...i)/1e3).toFixed(2)} s`;if(r.length){t.push(`Recorded: frames over ${o(r)}${s.length?`; cost columns (Chrome's counters) over ${o(s)}`:""}, not the requested time.`)}if(e.replay?.dirty)t.push(`Note: the project had uncommitted changes, so commit ${e.replay.commit?.slice(0,10)??"?"} alone doesn't describe the code that ran.`);t.push("");t.push(`**How to read this.** Dropped % is the smoothness number: frames Chrome dropped or only partly produced that it says hurt smoothness, as a share of the frames it wanted, from its own verdicts in a trace. Rank on it first, and only across variants doing the same work. Shown FPS counts frames Chrome produced (headless Chrome displays nothing), so an animation that changes less often needs fewer; don't compare it across variants that update at different rates. Main-thread FPS and frame gaps come from requestAnimationFrame. Main thread % is Chrome's 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 a=Array.from(new Set(e.results.map(i=>i.cpu)));for(const i of a){t.push("");t.push(i===1?`#### No CPU slowdown`:`#### CPU slowed ${i}x`);t.push("");t.push(`| Case | Runs | Shown FPS | Dropped % | Main-thread FPS (range) | p95 gap ms | Worst gap ms | Main thread % | Script ms | Style ms | Layout ms | Layouts | DOM nodes | JS heap MB | Flags |`);t.push(`|------|-----:|----------:|----------:|------------------------:|-----------:|-------------:|--------------:|----------:|---------:|----------:|--------:|----------:|--------:|-------|`);for(const d of e.results.filter(m=>m.cpu===i)){const m=d.median;if(!m){const b=d.runs.find(p=>p.failed)?.failed??"no valid runs";t.push(`| ${d.caseName} | 0/${d.plannedRuns} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${b}) |`);continue}const w=d.fpsMin!=null&&d.fpsMax!=null&&d.validRuns>1?` (${n(d.fpsMin)}\u2013${n(d.fpsMax)})`:"";const g=d.flags.length?`\u26A0\uFE0F ${d.flags.join("; ")}`:"no flags";t.push(`| ${d.isBaseline?`${d.caseName} (baseline)`:d.control?`${d.caseName} (control)`:d.caseName} | ${d.validRuns}/${d.plannedRuns} | ${n(m.shownFps)} | ${d.droppedMin==null&&d.tracedRuns>0?`\u2014 (${d.tracedRuns}/${d.validRuns} traced)`:n(d.droppedMin==null?null:m.droppedPct)}${d.droppedMin!=null&&d.validRuns>1?` [${d.droppedMin}\u2013${d.droppedMax}]`:""}${d.deltaDroppedPct!=null?` (${d.deltaDroppedPct>0?"+":""}${d.deltaDroppedPct} pp)`:""} | ${n(m.fps)}${w} | ${n(m.frameGapP95Ms)} | ${n(m.frameGapMaxMs)} | ${n(m.mainThreadPct)} | ${n(m.scriptMs,0)} | ${n(m.styleMs,0)} | ${n(m.layoutMs,0)} | ${n(m.layoutCount,0)} | ${n(m.domNodesEnd,0)} | ${n(m.heapMb)} | ${g} |`)}const c=closeCall(e.results.filter(d=>d.cpu===i));if(c){t.push("");t.push(c)}const u=e.results.filter(d=>d.cpu===i&&d.budget);if(u.length){t.push("");t.push(`Budget:`);for(const d of u){const m=d.budget;const w=[...m.over,...m.unknown.map(b=>`${b} not measured`)].join("; ");const g=d.control?`${d.caseName} (control)`:d.caseName;t.push(`- **${g}**: ${m.meets?"meets every limit":`misses (${w||"no result"})`}`)}}const l=e.results.filter(d=>d.cpu===i&&d.median?.interactions!=null&&(e.hasSteps||d.median.interactions>0)).map(d=>{const m=d.median;const w=d.inputMin!=null&&d.inputMax!=null?` \xB7 slowest across all runs ${d.inputMax} ms (range ${d.inputMin}\u2013${d.inputMax})`:"";const g=d.runs.filter(p=>!p.failed&&p.fps!=null&&p.inputCaptureComplete===false).length;const b=g?` \xB7 \u26A0\uFE0F timing incomplete in ${g} run(s): the last input's paint came after the run ended, so its time is unknown (leave more time after the last step)`:"";if(!m.interactions)return`- **${d.caseName}**: middle run had no input entry (inputs under 16 ms aren't reported)${w}${b}`;return`- **${d.caseName}**: middle run: ${m.interactions} reported (16 ms or more), slowest ${m.worstInteractionMs} ms (waiting ${m.worstInputDelayMs} \xB7 handlers ${m.worstProcessingMs} \xB7 to paint ${m.worstPresentationMs})${w}${b}`});if(l.length){t.push("");t.push(`Input response (scripted steps, Event Timing). Inputs under 16 ms aren't reported. Under 200 ms is good.`);t.push(...l)}const h=e.results.filter(d=>d.cpu===i&&d.median&&d.median.topScripts.length).map(d=>`- **${d.caseName}**: ${d.median.topScripts.slice(0,3).map(m=>`${m.name} ${m.ms} ms`).join(", ")}`);if(h.length){t.push("");t.push(`Scripts in slow frames (Long Animation Frames; frames that ran past the end of a run are left out):`);t.push(...h)}}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(i=>`\u26A0\uFE0F ${i}`))}return t.join("\n")}var import_node_module,import_node_child_process6,import_node_path5,import_node_fs5,import_node_os5,import_node_url,r1,COMMON_RATES,FRAME_STATES,PLAYWRIGHT_MISSING,FRAME_TRACE_CATEGORIES,FULL_TRACE_CATEGORIES,MAX_TRACE_CHARS,sleep2,LOCK_FILE,lockedHere,MIN_DROPPED_GAP_PP,MIN_FPS_GAP;var init_webBench=__esm({"src/webBench/webBench.ts"(){"use strict";import_node_module=require("module");import_node_child_process6=require("child_process");import_node_path5=require("path");import_node_fs5=require("fs");import_node_os5=require("os");import_node_url=require("url");init_inPageProbe();r1=e=>Math.round(e*10)/10;COMMON_RATES=[30,60,75,90,120,144,165,240];FRAME_STATES=["STATE_PRESENTED_ALL","STATE_PRESENTED_PARTIAL","STATE_DROPPED"];PLAYWRIGHT_MISSING="Playwright isn't installed in this project. Install it, then run again:\n npm i -D playwright && npx playwright install chromium";FRAME_TRACE_CATEGORIES=["disabled-by-default-devtools.timeline.frame","blink.user_timing"];FULL_TRACE_CATEGORIES=["devtools.timeline","disabled-by-default-devtools.timeline","disabled-by-default-devtools.timeline.frame","blink.user_timing","loading","latencyInfo","v8.execute"];MAX_TRACE_CHARS=15e7;sleep2=e=>new Promise(n=>setTimeout(n,e));LOCK_FILE=(0,import_node_path5.join)((0,import_node_os5.tmpdir)(),"buoy-web-bench.lock");lockedHere=false;MIN_DROPPED_GAP_PP=1.5;MIN_FPS_GAP=2}});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 a=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!a)continue;const i=a[2].trim();const c=i.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(a[1],c?c[2]:i.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 i=(0,import_headless.checkDevToken)(e);if(!i.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),i.claims.iat*1e3);const u=i.claims.tier==="free"?"free":"pro";this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`dev:${i.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(i.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 i=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(!i.ok){if(i.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=i.claims.exp*1e3;this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`service:${i.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 i=(0,import_headless.verifyFreeToken)(e);if(!i){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`token:${i.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 a=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:a,lastValidatedAt:a});this.persistCache();this.retry=0;this.nextCheck=a+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}
|
|
145
|
-
`));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:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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(a=>a.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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}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 a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};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 a=(0,import_node_crypto2.randomUUID)();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const h=setTimeout(()=>{this.pendingActions.delete(a);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:h,target:o});this.socket.emit("devtool-action",{requestId:a,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.50"?"7.0.50":"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(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return json({devices:r,...i.length>0?{warning:`${i.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:a?"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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:a})}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:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return json({toolId:t,action:r,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}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}
|
|
145
|
+
`));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:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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(a=>a.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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}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 a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};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 a=(0,import_node_crypto2.randomUUID)();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const h=setTimeout(()=>{this.pendingActions.delete(a);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:h,target:o});this.socket.emit("devtool-action",{requestId:a,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.52"?"7.0.52":"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(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return json({devices:r,...i.length>0?{warning:`${i.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:a?"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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:a})}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:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return json({toolId:t,action:r,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}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}
|
|
146
146
|
|
|
147
147
|
`:""}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((h,d)=>{const m=(h.name||"").trim()||`case ${d+1}`;return{id:`case-${d}`,name:m,params:h.params??{},route:h.route}});const a=clamp(Math.round(pick(e.runsPerCase,n.runsPerCase,FALLBACK.runsPerCase)),1,10);const i=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,a-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(a===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:a,coolDownMs:i,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 a=[`**${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 i=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${a.join(", ")}. That's ~${n(i)} 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("Low main-thread frame rate")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}const r=Math.max(e.frameGapMaxMs??0,e.edgeSilenceMs??0);if(r>=100)n.push(`Freeze of ${Math.round(r)} 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 grew every run (+${e.domGrowth} nodes): check for a leak`)}return n}function domGrowthAcrossRuns(e){const n=e.filter(r=>!r.batchFailureReason&&!r.discardedWarmup).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 a=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,f)=>p-f);const i=p=>{const f=s.filter(k=>(k.batchIndex??0)===p);if(f.length===0)return void 0;const v=f.filter(k=>!k.batchFailureReason&&!k.discardedWarmup);const y=v.filter(k=>k.webRun?.fps!=null);if(y.length>0&&y.length===v.length){const k=[...y].sort((S,_)=>S.webRun.fps-_.webRun.fps);return k[Math.floor((k.length-1)/2)]}if(v.length>0){return v.find(k=>k.isMedianRun===true)??v.find(k=>k.runIndex===0||k.runIndex===void 0)??v[0]}return f.find(k=>k.batchFailureReason)??f[0]};const c=p=>typeof p==="number"?p:null;const u=a.map(p=>{const f=i(p);const v=s.filter(D=>(D.batchIndex??0)===p);const y=v.length;const k=v.filter(D=>!D.batchFailureReason&&!D.discardedWarmup);const S=D=>D.webRun?D.webRun.fps??D.jsFpsAvg:D.uiFpsAvg;const _=k.map(S).filter(D=>typeof D==="number");const M=f?.name??t?.[p]?.name??`case ${p+1}`;const B=s.some(D=>(D.batchIndex??0)===p&&D.isBaseline===true);const I=o.get(p)??f?.batchFailureReason;const x=v.every(D=>D.batchFailureReason||D.discardedWarmup);const $=Boolean(I)||!f||x;const T={batchIndex:p,caseName:M,params:f?.params??{},runs:y,failed:$,...f?.route?{route:f.route}:{},...B?{isBaseline:true}:{},failureReason:$?I??(x&&f?"only warmup runs were saved":"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:k.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 A=f?.webRun;if(!A)return{...T,flags:$?[]:computeFlags(T)};const U=[...A.longTasksSupported?[]:["long tasks"],...A.heapSupported?[]:["JS heap"],...A.loaf===null?["long animation frames"]:[]];const N={...T,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:A.heapSupported?T.memMb:null,deviceMaxRefreshRate:Math.max(T.deviceMaxRefreshRate??0,A.refreshHz??0)||null,longTaskPct:A.longTaskPct,frameGapP95Ms:A.frameGapP95Ms,frameGapMaxMs:A.frameGapMaxMs,edgeSilenceMs:A.edgeSilenceMs??null,jsFps:A.fps??T.jsFps,longFrames:A.longFrames,...U.length?{missingMetrics:U}:{},loafBlockingMs:A.loaf?.blockingMs??null,styleLayoutMs:A.loaf?.styleLayoutMs??null,forcedLayoutMs:A.loaf?.forcedLayoutMs??null,topScripts:A.loaf?.topScripts??[],worstInteractionMs:A.interactions?.worstMs??null,layoutShift:A.layoutShift??null,domNodes:A.domNodesEnd??null,domGrowth:domGrowthAcrossRuns(v),pageLoad:A.pageLoad??null,refreshCalibrated:A.refreshCalibrated??null};return{...N,flags:$?[]:computeWebFlags({...N,frames:A.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 h=u.filter(p=>!p.failed);const d=(p,f)=>{let v;let y=NaN;for(const k of h){const S=p(k);if(S==null)continue;if(v===void 0||f(S,y)){v=k;y=S}}return v?.caseName};const m=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const w={uiFps:d(p=>p.uiFps,(p,f)=>p>f),jsFps:d(p=>p.jsFps,(p,f)=>p>f),lowestCpu:d(p=>p.cpu,(p,f)=>p<f),lowestMem:d(p=>p.memMb,(p,f)=>p<f),fewestJank:d(m,(p,f)=>p<f),fewestLongTasks:d(p=>p.longTaskPct??null,(p,f)=>p<f)};const g=h.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const b=h.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:h.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:w,flagged:g,balanced:b}}function pageLoadLines(e){const n=e.filter(a=>!a.failed);if(n.length===0)return[];const t=n.map(a=>a.pageLoad?.navigationId);if(t.some(a=>a==null)||new Set(t).size!==t.length)return[];if(n.some(a=>(a.pageLoad?.pageAgeMs??Infinity)>3e4))return[];const r=a=>(a??"").replace(/\/+$/,"")||"/";const s=a=>{const i=a.pageLoad;if(!i?.loadPath||r(i.loadPath)!==r(a.route))return false;const c=new URLSearchParams(i.loadSearch??"");const u=[...c.keys()];if(new Set(u).size!==u.length)return false;const l=Object.entries(a.params);return u.length===l.length&&l.every(([h,d])=>c.get(h)===d)};if(n.some(a=>!s(a)))return[];const o=a=>a==null?"\u2014":String(Math.round(a));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(a=>{const i=a.pageLoad;return`| ${a.caseName} | ${o(i.ttfbMs)} | ${o(i.fcpMs)} | ${o(i.lcpMs)} | ${o(i.domContentLoadedMs)} | ${o(i.scriptKb)} | ${o(i.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,a)=>(n(a)??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)||(n(r)??0)-(n(s)??0)<=2){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap or sit within 2 FPS. No clear winner between them; more runs may separate them.`}return null}function formatRankingMarkdown(e){const n=(d,m=1)=>d==null?"\u2014":d.toFixed(m);const t=d=>d==null?"":d>0?` (+${d.toFixed(1)})`:` (${d.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(d=>d.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(d=>d.thermalState);const o=s?" Thermal |":"";const a=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${a}-------|`);for(const d of e.rows){const m=s?` ${THERMAL_MARK[d.thermalState??"unknown"]??"\u2014"} |`:"";if(d.failed){r.push(`| ${d.batchIndex} | ${d.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${m} \u274C FAILED (${d.failureReason}) |`);continue}const w=d.flags.length?`\u26A0\uFE0F ${d.flags.join("; ")}`:"no flags";r.push(`| ${d.batchIndex} | ${d.caseName} | ${n(d.uiFps)}${t(d.deltaUiFps)} | ${n(d.jsFps)}${t(d.deltaJsFps)} | ${n(d.cpu)} | ${n(d.memMb)} | ${n(d.jsJank,0)} | ${n(d.uiJank,0)} |${m} ${w} |`)}const i=closeCallNote(e.rows);if(i){r.push("");r.push(i)}const c=e.rows.filter(d=>d.thermalState==="serious"||d.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(d=>d.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(d=>`${d.caseName} (${d.flags.join("; ")})`).join(" \xB7 ")}`)}const h=e.rows.filter(d=>!d.failed&&(d.renderCommits!=null||d.topRenderers.length>0)).map(d=>{const m=d.topRenderers.map(b=>`${b.name} \xD7${b.renders} (${b.totalMs}ms${b.cause?`, ${b.cause}`:""})`).join(", ");const w=d.renderCommits!=null?`${d.renderCommits} commits`:"";const g=d.renderWasted!=null?`${d.renderWasted} wasted`:"";return`- **${d.caseName}** \u2014 ${[w,g,m].filter(Boolean).join(": ")}`});if(h.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(...h)}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 compareWebReports(e,n){const t=(a,i,c)=>({a:a??null,b:i??null,delta:a!=null&&i!=null?round(i-a,1):null,better:c});const r=a=>a.webRun.heapSupported?round(a.stats.memoryUsage.max,1):null;const s=e.webRun;const o=n.webRun;return{a:e.name,b:n.name,platform:"web",note:"Browser runs: FPS is the main thread's whole-run requestAnimationFrame rate; long tasks % is not CPU. null = this browser couldn't measure it.",fps:t(s.fps??e.stats.jsFps.avg,o.fps??n.stats.jsFps.avg,"higher"),frameGapP95Ms:t(s.frameGapP95Ms,o.frameGapP95Ms,"lower"),frameGapMaxMs:t(s.frameGapMaxMs,o.frameGapMaxMs,"lower"),longFrames:t(s.longFrames,o.longFrames,"lower"),longTaskPct:t(s.longTaskPct,o.longTaskPct,"lower"),slowFrameBlockingMs:t(s.loaf?.blockingMs,o.loaf?.blockingMs,"lower"),styleLayoutPaintMs:t(s.loaf?.styleLayoutMs,o.loaf?.styleLayoutMs,"lower"),forcedLayoutMs:t(s.loaf?.forcedLayoutMs,o.loaf?.forcedLayoutMs,"lower"),worstInteractionMs:t(s.interactions?.worstMs,o.interactions?.worstMs,"lower"),layoutShift:t(s.layoutShift,o.layoutShift,"lower"),domNodes:t(s.domNodesEnd,o.domNodesEnd,"lower"),jsHeapMb:t(r(e),r(n),"lower"),topScripts:{a:s.loaf?.topScripts??[],b:o.loaf?.topScripts??[]},...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,h=1)=>l==null?"\u2014":l.toFixed(h);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**How to read this.** 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. For Chrome's own dropped-frame count, use run_web_benchmark.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | JS heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const h=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${h} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const d=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"no flags";n.push(`| ${l.batchIndex} | ${h} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${d} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const h=[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(d=>`${d.name} ${d.ms} ms`).join(", ")}`].filter(Boolean);return h.length?`- **${l.caseName}**: ${h.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size; slow frames that ran past a run's end are left out):`);n.push(...s)}const o=pageLoadLines(e.rows);if(o.length){n.push("");n.push(...o)}const a=closeCallNote(e.rows);if(a){n.push("");n.push(a)}const i=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(i.length){n.push("");n.push(`**Not measured in this browser:** ${i.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.rows.some(l=>l.refreshCalibrated===false)){n.push("");n.push(`**Refresh rate not checked on an idle screen:** the batch couldn't reach its bounce screen first, so the screen rate (and frame budgets) may read low.`)}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:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:h}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:h}}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:a}=t;const i=t.reloadsBetweenCases?10:3;const c=Date.now();let u="";let l=false;let h=0;while(Date.now()-c<o){let w;try{w=await readPerfSnapshot(e,n)}catch{await delay2(POLL_INTERVAL_MS);continue}const g=w.index??[];const b=w.live?.automation?.phase??"idle";const p=w.live?.automationCompleted;if(p?.phase==="cancelled"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:[],cancelled:true}}if(p?.phase==="done"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:p.failures??failuresFromIndex(g,p.batchId)}}if(b!=="idle")l=true;if(b!==u){u=b;a(`automation phase: ${b}`)}const f=[...batchIdsIn(g)].find(v=>!r.has(v));if(f){const v=runCountFor(g,f);if(s>0&&v>=s){await waitForRunnerSettled(e,n,a);a(`batch ${f} complete (${v}/${s} runs saved)`);return{batchId:f,failures:failuresFromIndex(g,f)}}const y=(g??[]).filter(M=>M.batchId===f&&M.name!==WARMUP_CASE_NAME).length;const k=w.live?.pendingBatchId===f;const S=Boolean(w.live&&"pendingBatchId"in w.live);const _=!t.reloadsBetweenCases||S;if(b==="idle"&&l&&y>0&&!k&&_){if(++h>=i){a(`batch ${f} settled (${v} runs, automation idle)`);return{batchId:f,failures:failuresFromIndex(g,f)}}}else{h=0}}await delay2(POLL_INTERVAL_MS)}const d=await readPerfSnapshot(e,n).catch(()=>null);const m=d?[...batchIdsIn(d.index)].find(w=>!r.has(w)):void 0;if(m&&d)return{batchId:m,failures:failuresFromIndex(d.index,m),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${u||"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}
|
|
148
148
|
`);try{const s=n.requireProDevice(t.deviceId);const o=await readDeviceSettings(n,s);const{config:a,warnings:i}=buildAutomationConfig(t,o);const c=estimateBatchMs(a);const u=t.maxWaitMs??c*3+6e4;const l=expectedRunsFor(a);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 h=await readPerfSnapshot(n,s);const d=batchIdsIn(h.index);r(`starting batch: ${a.cases.length} cases \xD7 ${a.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(PERF_TOOL_ID,"startAutomation",{config:a},s);const m=await waitForBatchByIndex(n,s,{preBatchIds:d,expectedRuns:l,maxWaitMs:u,log:r,reloadsBetweenCases:a.reloadBetweenCases===true});if(m.cancelled){await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});return fail2(`Batch ${m.batchId} was cancelled before completing.`)}await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const w=await readPerfSnapshot(n,s);const g=rankBatchFromIndex(w.index??[],m.batchId,a.cases,m.failures);await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:false},s).catch(()=>{});if(g.rows.length===0){const f=n.connectedDevices().some(k=>k.deviceId===s);const v=w.live?.pendingBatchId===m.batchId;const y=!f?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:v?`The batch reloaded the app between cases and never resumed. Check that <AutomationResumer/> is mounted (the web host mounts it), or pass reloadBetweenCases: false.`:m.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 ${m.batchId||"(no id)"} saved no runs. ${y}`)}const b=formatRankingMarkdown(g);const p={batchId:m.batchId,leaders:g.leaders,balanced:g.balanced,flagged:g.flagged,rows:g.rows,failures:m.failures,warnings:i,estimateMs:c,...m.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return text(`${b}
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{SyncClient as u,createMcpServer as d,runInit as w}from"./chunk-
|
|
2
|
+
import{SyncClient as u,createMcpServer as d,runInit as w}from"./chunk-JULJYHPG.mjs";import"./chunk-RYFN5J56.mjs";import"./chunk-EKM5ZZKU.mjs";import{StdioServerTransport as f}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 m(){const e=process.env.BUOY_BROKER_PORT;const s=new u({url:process.env.BUOY_BROKER_URL,port:e?Number(e):void 0,autoSpawnBroker:process.env.BUOY_NO_SPAWN!=="1"});const o=d(s);const r=new f;let n=false;const c=t=>{if(n)return;n=true;process.stderr.write(`[buoy-mcp] shutting down (${t})
|
|
3
3
|
`);setTimeout(()=>process.exit(0),2e3).unref();void(async()=>{await s.stop().catch(()=>{});await o.close().catch(()=>{});process.exit(0)})()};process.stdin.once("end",()=>c("stdin closed"));process.stdin.once("close",()=>c("stdin closed"));o.server.onclose=()=>c("transport closed");process.on("SIGINT",()=>c("SIGINT"));process.on("SIGTERM",()=>c("SIGTERM"));process.on("SIGHUP",()=>c("SIGHUP"));await o.connect(r);process.stderr.write(`[buoy-mcp] serving MCP over stdio; connecting to broker\u2026
|
|
4
4
|
`);s.start().then(t=>process.stderr.write(`[buoy-mcp] broker ${t}
|
|
5
5
|
`)).catch(t=>process.stderr.write(`[buoy-mcp] broker connection failed: ${t?.message??t} \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 a=o.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!a)continue;const i=a[2].trim();const c=i.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);s.set(a[1],c?c[2]:i.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 i=(0,import_headless.checkDevToken)(e);if(!i.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),i.claims.iat*1e3);const u=i.claims.tier==="free"?"free":"pro";this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`dev:${i.claims.sid}`,valid:true,tier:u,isPro:u==="pro",expiresAt:new Date(i.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 i=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(!i.ok){if(i.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=i.claims.exp*1e3;this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`service:${i.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 i=(0,import_headless.verifyFreeToken)(e);if(!i){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`token:${i.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 a=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:a,lastValidatedAt:a});this.persistCache();this.retry=0;this.nextCheck=a+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:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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(a=>a.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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}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 a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};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 a=(0,import_node_crypto2.randomUUID)();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const h=setTimeout(()=>{this.pendingActions.delete(a);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:h,target:o});this.socket.emit("devtool-action",{requestId:a,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.50"?"7.0.50":"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(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return json({devices:r,...i.length>0?{warning:`${i.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:a?"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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:a})}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:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return json({toolId:t,action:r,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}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:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(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",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.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 a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(r)return;r=true;clearTimeout(o);n(new Error(a(i)?`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}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){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=i;const u=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,u);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.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(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}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(a=>a.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:a}of this.watching.values()){if(a===t)this.sendWatch(o,a)}}}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 a=this.waitForSnapshot(s,o);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:r});const i=await a;this.requireAdmission();this.requireDeviceAccount(r);if(i!==void 0){if(!t.silent)this.noteOk(r);return{deviceId:r,payload:i}}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 a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==o);this.snapshotWaiters.set(e,a);t(void 0)},n);const o=a=>{clearTimeout(s);t(a)};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 a=(0,import_node_crypto2.randomUUID)();const i=s.timeoutMs??this.timeoutFor(o,e,n);const c=await new Promise((u,l)=>{const h=setTimeout(()=>{this.pendingActions.delete(a);if(o!==ALL_DEVICES&&!s.silent)this.noteTimeout(o);l(new Error(`Action "${e}.${n}" got no answer in ${i}ms. `+(o===ALL_DEVICES?"No device responded.":this.unresponsiveNote(o))))},i);this.pendingActions.set(a,{resolve:u,reject:l,timer:h,target:o});this.socket.emit("devtool-action",{requestId:a,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.52"?"7.0.52":"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(a=>Number(a)||0);const r=t(e);const s=t(n);for(let o=0;o<3;o++){const a=r[o]??0;const i=s[o]??0;if(a>i)return true;if(a<i)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 a=s.some(c=>c.tier!=="locked");const i=s.filter(c=>c.collision);return json({devices:r,...i.length>0?{warning:`${i.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:a?"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:a}=await n.getSnapshot(t,o,{timeoutMs:s});return json({toolId:t,deviceId:o,snapshot:a})}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:a})=>{try{const i=n.requireProDevice(o);const c=await n.callAction(t,r,s,i,{timeoutMs:a});return json({toolId:t,action:r,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}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((h,d)=>{const m=(h.name||"").trim()||`case ${d+1}`;return{id:`case-${d}`,name:m,params:h.params??{},route:h.route}});const a=clamp(Math.round(pick(e.runsPerCase,n.runsPerCase,FALLBACK.runsPerCase)),1,10);const i=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,a-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(a===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:a,coolDownMs:i,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 a=[`**${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 i=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${a.join(", ")}. That's ~${n(i)} 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("Low main-thread frame rate")}else if(e.longFrames!=null&&e.frames>0&&e.longFrames/e.frames>=.02){n.push("Frequent long frames")}const r=Math.max(e.frameGapMaxMs??0,e.edgeSilenceMs??0);if(r>=100)n.push(`Freeze of ${Math.round(r)} 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 grew every run (+${e.domGrowth} nodes): check for a leak`)}return n}function domGrowthAcrossRuns(e){const n=e.filter(r=>!r.batchFailureReason&&!r.discardedWarmup).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 a=Array.from(new Set(s.map(p=>p.batchIndex??0))).sort((p,f)=>p-f);const i=p=>{const f=s.filter(k=>(k.batchIndex??0)===p);if(f.length===0)return void 0;const v=f.filter(k=>!k.batchFailureReason&&!k.discardedWarmup);const y=v.filter(k=>k.webRun?.fps!=null);if(y.length>0&&y.length===v.length){const k=[...y].sort((S,_)=>S.webRun.fps-_.webRun.fps);return k[Math.floor((k.length-1)/2)]}if(v.length>0){return v.find(k=>k.isMedianRun===true)??v.find(k=>k.runIndex===0||k.runIndex===void 0)??v[0]}return f.find(k=>k.batchFailureReason)??f[0]};const c=p=>typeof p==="number"?p:null;const u=a.map(p=>{const f=i(p);const v=s.filter(D=>(D.batchIndex??0)===p);const y=v.length;const k=v.filter(D=>!D.batchFailureReason&&!D.discardedWarmup);const S=D=>D.webRun?D.webRun.fps??D.jsFpsAvg:D.uiFpsAvg;const _=k.map(S).filter(D=>typeof D==="number");const M=f?.name??t?.[p]?.name??`case ${p+1}`;const q=s.some(D=>(D.batchIndex??0)===p&&D.isBaseline===true);const I=o.get(p)??f?.batchFailureReason;const x=v.every(D=>D.batchFailureReason||D.discardedWarmup);const $=Boolean(I)||!f||x;const T={batchIndex:p,caseName:M,params:f?.params??{},runs:y,failed:$,...f?.route?{route:f.route}:{},...q?{isBaseline:true}:{},failureReason:$?I??(x&&f?"only warmup runs were saved":"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:k.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 A=f?.webRun;if(!A)return{...T,flags:$?[]:computeFlags(T)};const U=[...A.longTasksSupported?[]:["long tasks"],...A.heapSupported?[]:["JS heap"],...A.loaf===null?["long animation frames"]:[]];const N={...T,platform:"web",uiFps:null,uiJank:null,jsJank:null,cpu:null,memMb:A.heapSupported?T.memMb:null,deviceMaxRefreshRate:Math.max(T.deviceMaxRefreshRate??0,A.refreshHz??0)||null,longTaskPct:A.longTaskPct,frameGapP95Ms:A.frameGapP95Ms,frameGapMaxMs:A.frameGapMaxMs,edgeSilenceMs:A.edgeSilenceMs??null,jsFps:A.fps??T.jsFps,longFrames:A.longFrames,...U.length?{missingMetrics:U}:{},loafBlockingMs:A.loaf?.blockingMs??null,styleLayoutMs:A.loaf?.styleLayoutMs??null,forcedLayoutMs:A.loaf?.forcedLayoutMs??null,topScripts:A.loaf?.topScripts??[],worstInteractionMs:A.interactions?.worstMs??null,layoutShift:A.layoutShift??null,domNodes:A.domNodesEnd??null,domGrowth:domGrowthAcrossRuns(v),pageLoad:A.pageLoad??null,refreshCalibrated:A.refreshCalibrated??null};return{...N,flags:$?[]:computeWebFlags({...N,frames:A.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 h=u.filter(p=>!p.failed);const d=(p,f)=>{let v;let y=NaN;for(const k of h){const S=p(k);if(S==null)continue;if(v===void 0||f(S,y)){v=k;y=S}}return v?.caseName};const m=p=>p.platform==="web"?p.longFrames??null:p.jsJank==null&&p.uiJank==null?null:(p.jsJank??0)+(p.uiJank??0);const w={uiFps:d(p=>p.uiFps,(p,f)=>p>f),jsFps:d(p=>p.jsFps,(p,f)=>p>f),lowestCpu:d(p=>p.cpu,(p,f)=>p<f),lowestMem:d(p=>p.memMb,(p,f)=>p<f),fewestJank:d(m,(p,f)=>p<f),fewestLongTasks:d(p=>p.longTaskPct??null,(p,f)=>p<f)};const g=h.filter(p=>p.flags.length>0).map(p=>({caseName:p.caseName,flags:p.flags}));const b=h.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:h.find(p=>p.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:u,leaders:w,flagged:g,balanced:b}}function pageLoadLines(e){const n=e.filter(a=>!a.failed);if(n.length===0)return[];const t=n.map(a=>a.pageLoad?.navigationId);if(t.some(a=>a==null)||new Set(t).size!==t.length)return[];if(n.some(a=>(a.pageLoad?.pageAgeMs??Infinity)>3e4))return[];const r=a=>(a??"").replace(/\/+$/,"")||"/";const s=a=>{const i=a.pageLoad;if(!i?.loadPath||r(i.loadPath)!==r(a.route))return false;const c=new URLSearchParams(i.loadSearch??"");const u=[...c.keys()];if(new Set(u).size!==u.length)return false;const l=Object.entries(a.params);return u.length===l.length&&l.every(([h,d])=>c.get(h)===d)};if(n.some(a=>!s(a)))return[];const o=a=>a==null?"\u2014":String(Math.round(a));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(a=>{const i=a.pageLoad;return`| ${a.caseName} | ${o(i.ttfbMs)} | ${o(i.fcpMs)} | ${o(i.lcpMs)} | ${o(i.domContentLoadedMs)} | ${o(i.scriptKb)} | ${o(i.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,a)=>(n(a)??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)||(n(r)??0)-(n(s)??0)<=2){return`**Too close to call:** ${r.caseName} (${r.fpsMin}\u2013${r.fpsMax} FPS) and ${s.caseName} (${s.fpsMin}\u2013${s.fpsMax} FPS) overlap or sit within 2 FPS. No clear winner between them; more runs may separate them.`}return null}function formatRankingMarkdown(e){const n=(d,m=1)=>d==null?"\u2014":d.toFixed(m);const t=d=>d==null?"":d>0?` (+${d.toFixed(1)})`:` (${d.toFixed(1)})`;const r=[];r.push(`### Benchmark batch \`${e.batchId}\``);if(e.rows.some(d=>d.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(d=>d.thermalState);const o=s?" Thermal |":"";const a=s?"-------|":"";r.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${o} Flags |`);r.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${a}-------|`);for(const d of e.rows){const m=s?` ${THERMAL_MARK[d.thermalState??"unknown"]??"\u2014"} |`:"";if(d.failed){r.push(`| ${d.batchIndex} | ${d.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${m} \u274C FAILED (${d.failureReason}) |`);continue}const w=d.flags.length?`\u26A0\uFE0F ${d.flags.join("; ")}`:"no flags";r.push(`| ${d.batchIndex} | ${d.caseName} | ${n(d.uiFps)}${t(d.deltaUiFps)} | ${n(d.jsFps)}${t(d.deltaJsFps)} | ${n(d.cpu)} | ${n(d.memMb)} | ${n(d.jsJank,0)} | ${n(d.uiJank,0)} |${m} ${w} |`)}const i=closeCallNote(e.rows);if(i){r.push("");r.push(i)}const c=e.rows.filter(d=>d.thermalState==="serious"||d.thermalState==="critical");if(c.length){r.push("");r.push(`**\u{1F525} Thermally throttled: ${c.map(d=>d.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(d=>`${d.caseName} (${d.flags.join("; ")})`).join(" \xB7 ")}`)}const h=e.rows.filter(d=>!d.failed&&(d.renderCommits!=null||d.topRenderers.length>0)).map(d=>{const m=d.topRenderers.map(b=>`${b.name} \xD7${b.renders} (${b.totalMs}ms${b.cause?`, ${b.cause}`:""})`).join(", ");const w=d.renderCommits!=null?`${d.renderCommits} commits`:"";const g=d.renderWasted!=null?`${d.renderWasted} wasted`:"";return`- **${d.caseName}** \u2014 ${[w,g,m].filter(Boolean).join(": ")}`});if(h.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(...h)}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 compareWebReports(e,n){const t=(a,i,c)=>({a:a??null,b:i??null,delta:a!=null&&i!=null?round(i-a,1):null,better:c});const r=a=>a.webRun.heapSupported?round(a.stats.memoryUsage.max,1):null;const s=e.webRun;const o=n.webRun;return{a:e.name,b:n.name,platform:"web",note:"Browser runs: FPS is the main thread's whole-run requestAnimationFrame rate; long tasks % is not CPU. null = this browser couldn't measure it.",fps:t(s.fps??e.stats.jsFps.avg,o.fps??n.stats.jsFps.avg,"higher"),frameGapP95Ms:t(s.frameGapP95Ms,o.frameGapP95Ms,"lower"),frameGapMaxMs:t(s.frameGapMaxMs,o.frameGapMaxMs,"lower"),longFrames:t(s.longFrames,o.longFrames,"lower"),longTaskPct:t(s.longTaskPct,o.longTaskPct,"lower"),slowFrameBlockingMs:t(s.loaf?.blockingMs,o.loaf?.blockingMs,"lower"),styleLayoutPaintMs:t(s.loaf?.styleLayoutMs,o.loaf?.styleLayoutMs,"lower"),forcedLayoutMs:t(s.loaf?.forcedLayoutMs,o.loaf?.forcedLayoutMs,"lower"),worstInteractionMs:t(s.interactions?.worstMs,o.interactions?.worstMs,"lower"),layoutShift:t(s.layoutShift,o.layoutShift,"lower"),domNodes:t(s.domNodesEnd,o.domNodesEnd,"lower"),jsHeapMb:t(r(e),r(n),"lower"),topScripts:{a:s.loaf?.topScripts??[],b:o.loaf?.topScripts??[]},...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,h=1)=>l==null?"\u2014":l.toFixed(h);const r=l=>l==null?"":l>0?` (+${l.toFixed(1)})`:` (${l.toFixed(1)})`;n.push(`**How to read this.** 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. For Chrome's own dropped-frame count, use run_web_benchmark.`);n.push("");n.push(`| # | Case | FPS | p95 frame gap ms | Worst gap ms | Long frames | Long tasks % | JS heap MB | Flags |`);n.push(`|---|------|----:|-----------------:|-------------:|------------:|-------------:|--------:|-------|`);for(const l of e.rows){const h=l.isBaseline?`${l.caseName} (baseline)`:l.caseName;if(l.failed){n.push(`| ${l.batchIndex} | ${h} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${l.failureReason}) |`);continue}const d=l.flags.length?`\u26A0\uFE0F ${l.flags.join("; ")}`:"no flags";n.push(`| ${l.batchIndex} | ${h} | ${t(l.jsFps)}${r(l.deltaJsFps)} | ${t(l.frameGapP95Ms)} | ${t(l.frameGapMaxMs)} | ${t(l.longFrames,0)} | ${t(l.longTaskPct)} | ${t(l.memMb)} | ${d} |`)}const s=e.rows.filter(l=>!l.failed).map(l=>{const h=[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(d=>`${d.name} ${d.ms} ms`).join(", ")}`].filter(Boolean);return h.length?`- **${l.caseName}**: ${h.join(" \xB7 ")}`:null}).filter(Boolean);if(s.length){n.push("");n.push(`**Why frames were slow** (Long Animation Frames, Event Timing, DOM size; slow frames that ran past a run's end are left out):`);n.push(...s)}const o=pageLoadLines(e.rows);if(o.length){n.push("");n.push(...o)}const a=closeCallNote(e.rows);if(a){n.push("");n.push(a)}const i=Array.from(new Set(e.rows.flatMap(l=>l.missingMetrics??[])));if(i.length){n.push("");n.push(`**Not measured in this browser:** ${i.join(", ")}. These show as \u2014 and can't win or lose.`)}if(e.rows.some(l=>l.refreshCalibrated===false)){n.push("");n.push(`**Refresh rate not checked on an idle screen:** the batch couldn't reach its bounce screen first, so the screen rate (and frame budgets) may read low.`)}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:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:h}=t;return{perCaseDurationMs:r,settleMs:s,navTimeoutMs:o,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:u,reloadBetweenCases:l,captureRenders:h}}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:a}=t;const i=t.reloadsBetweenCases?10:3;const c=Date.now();let u="";let l=false;let h=0;while(Date.now()-c<o){let w;try{w=await readPerfSnapshot(e,n)}catch{await delay2(POLL_INTERVAL_MS);continue}const g=w.index??[];const b=w.live?.automation?.phase??"idle";const p=w.live?.automationCompleted;if(p?.phase==="cancelled"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:[],cancelled:true}}if(p?.phase==="done"&&!r.has(p.batchId)){return{batchId:p.batchId,failures:p.failures??failuresFromIndex(g,p.batchId)}}if(b!=="idle")l=true;if(b!==u){u=b;a(`automation phase: ${b}`)}const f=[...batchIdsIn(g)].find(v=>!r.has(v));if(f){const v=runCountFor(g,f);if(s>0&&v>=s){await waitForRunnerSettled(e,n,a);a(`batch ${f} complete (${v}/${s} runs saved)`);return{batchId:f,failures:failuresFromIndex(g,f)}}const y=(g??[]).filter(M=>M.batchId===f&&M.name!==WARMUP_CASE_NAME).length;const k=w.live?.pendingBatchId===f;const S=Boolean(w.live&&"pendingBatchId"in w.live);const _=!t.reloadsBetweenCases||S;if(b==="idle"&&l&&y>0&&!k&&_){if(++h>=i){a(`batch ${f} settled (${v} runs, automation idle)`);return{batchId:f,failures:failuresFromIndex(g,f)}}}else{h=0}}await delay2(POLL_INTERVAL_MS)}const d=await readPerfSnapshot(e,n).catch(()=>null);const m=d?[...batchIdsIn(d.index)].find(w=>!r.has(w)):void 0;if(m&&d)return{batchId:m,failures:failuresFromIndex(d.index,m),timedOut:true};throw new Error(`Batch did not start within ${Math.round(o/1e3)}s (last phase: ${u||"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:a,warnings:i}=buildAutomationConfig(t,o);const c=estimateBatchMs(a);const u=t.maxWaitMs??c*3+6e4;const l=expectedRunsFor(a);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 h=await readPerfSnapshot(n,s);const d=batchIdsIn(h.index);r(`starting batch: ${a.cases.length} cases \xD7 ${a.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${s}`);await n.callAction(PERF_TOOL_ID,"startAutomation",{config:a},s);const m=await waitForBatchByIndex(n,s,{preBatchIds:d,expectedRuns:l,maxWaitMs:u,log:r,reloadsBetweenCases:a.reloadBetweenCases===true});if(m.cancelled){await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});return fail2(`Batch ${m.batchId} was cancelled before completing.`)}await n.callAction(PERF_TOOL_ID,"refreshIndex",{},s).catch(()=>{});const w=await readPerfSnapshot(n,s);const g=rankBatchFromIndex(w.index??[],m.batchId,a.cases,m.failures);await n.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},s).catch(()=>{});await n.callAction(PERF_TOOL_ID,"setEnabled",{enabled:false},s).catch(()=>{});if(g.rows.length===0){const f=n.connectedDevices().some(k=>k.deviceId===s);const v=w.live?.pendingBatchId===m.batchId;const y=!f?`The device disconnected during the batch (a browser tab that closes, reloads or sleeps drops off). Reconnect it and run again.`:v?`The batch reloaded the app between cases and never resumed. Check that <AutomationResumer/> is mounted (the web host mounts it), or pass reloadBetweenCases: false.`:m.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 ${m.batchId||"(no id)"} saved no runs. ${y}`)}const b=formatRankingMarkdown(g);const p={batchId:m.batchId,leaders:g.leaders,balanced:g.balanced,flagged:g.flagged,rows:g.rows,failures:m.failures,warnings:i,estimateMs:c,...m.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return text(`${b}
|
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 m,registerJotaiTools as O,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 m,registerJotaiTools as O,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-JULJYHPG.mjs";import"./chunk-RYFN5J56.mjs";import"./chunk-EKM5ZZKU.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,m as registerGenericTools,O 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.52",
|
|
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.52",
|
|
39
|
+
"@buoy-gg/license": "^7.0.52"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "^22.0.0",
|