@buoy-gg/mcp 7.0.71 → 7.0.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/{chunk-K4R23SLC.mjs → chunk-AHIKIVTB.mjs} +1 -1
- package/dist/chunk-DZRRLTOD.mjs +4 -0
- package/dist/chunk-MJDNVDJI.mjs +474 -0
- package/dist/{chunk-ISZRKNVH.mjs → chunk-OADIHE3T.mjs} +1 -1
- package/dist/{chunk-PIMFQHV4.mjs → chunk-OUCQTYIW.mjs} +1 -1
- package/dist/{chunk-AN6T7AGX.mjs → chunk-S4JHE46Q.mjs} +92 -92
- package/dist/cli.js +305 -253
- package/dist/cli.mjs +6 -6
- package/dist/{flowRun-M652USJQ.mjs → flowRun-GTYQQXHF.mjs} +1 -1
- package/dist/index.js +30 -28
- package/dist/index.mjs +1 -1
- package/dist/{init-Z4SKOMSV.mjs → init-FDDGVIXA.mjs} +1 -1
- package/dist/{main-MGASA6BJ.mjs → main-DJT43WTJ.mjs} +2 -2
- package/dist/{simFarm-QD6SXEZE.mjs → simFarm-5MWIXNER.mjs} +1 -1
- package/dist/src-ZFIIN4L2.mjs +1 -0
- package/package.json +4 -4
- package/skill/buoy-optimize/MEASURING.md +11 -2
- package/skill/buoy-optimize/SKILL.md +1 -1
- package/dist/chunk-EVLLWLFB.mjs +0 -4
- package/dist/chunk-N573FXZ7.mjs +0 -422
- package/dist/src-TOKDOFTX.mjs +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createRequire as r}from"module";const L=r(import.meta.url);import{ALL_DEVICES as e,BROKER_PORT as o,BROKER_URL as t,DASHBOARD_NAME as i,DEFAULT_ACTION_TIMEOUT_MS as s,DEFAULT_SNAPSHOT_TIMEOUT_MS as m,SyncClient as a,buildAutomationConfig as T,compareReportStats as n,createMcpServer as p,estimateBatchMs as g,formatRankingMarkdown as l,getUpdateNotice as R,rankBatchFromIndex as E,registerConsoleTools as _,registerEventsTools as c,registerGenericTools as u,registerJotaiTools as A,registerPerfMonitorTools as S,registerReactQueryTools as M,registerReduxTools as O,registerRoutesTools as f,registerScreenshotTools as C,registerStorageTools as I,registerZustandTools as d}from"./chunk-
|
|
1
|
+
import{createRequire as r}from"module";const L=r(import.meta.url);import{ALL_DEVICES as e,BROKER_PORT as o,BROKER_URL as t,DASHBOARD_NAME as i,DEFAULT_ACTION_TIMEOUT_MS as s,DEFAULT_SNAPSHOT_TIMEOUT_MS as m,SyncClient as a,buildAutomationConfig as T,compareReportStats as n,createMcpServer as p,estimateBatchMs as g,formatRankingMarkdown as l,getUpdateNotice as R,rankBatchFromIndex as E,registerConsoleTools as _,registerEventsTools as c,registerGenericTools as u,registerJotaiTools as A,registerPerfMonitorTools as S,registerReactQueryTools as M,registerReduxTools as O,registerRoutesTools as f,registerScreenshotTools as C,registerStorageTools as I,registerZustandTools as d}from"./chunk-S4JHE46Q.mjs";import{runInit as N}from"./chunk-OADIHE3T.mjs";import{PACKAGE_NAME as U,VERSION as B}from"./chunk-AHIKIVTB.mjs";import"./chunk-DZRRLTOD.mjs";import"./chunk-MJDNVDJI.mjs";import"./chunk-OUCB2Y5J.mjs";import"./chunk-FOMLVT7K.mjs";import"./chunk-OP23W722.mjs";import"./chunk-DVRR7W22.mjs";import"./chunk-MTTWR4T7.mjs";export{e as ALL_DEVICES,o as BROKER_PORT,t as BROKER_URL,i as DASHBOARD_NAME,s as DEFAULT_ACTION_TIMEOUT_MS,m as DEFAULT_SNAPSHOT_TIMEOUT_MS,U as PACKAGE_NAME,a as SyncClient,B as VERSION,T as buildAutomationConfig,n as compareReportStats,p as createMcpServer,g as estimateBatchMs,l as formatRankingMarkdown,R as getUpdateNotice,E as rankBatchFromIndex,_ as registerConsoleTools,c as registerEventsTools,u as registerGenericTools,A as registerJotaiTools,S as registerPerfMonitorTools,M as registerReactQueryTools,O as registerReduxTools,f as registerRoutesTools,C as registerScreenshotTools,I as registerStorageTools,d as registerZustandTools,N as runInit};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createRequire as w}from"module";const I=w(import.meta.url);import{ensureHelper as g,helperManifestDirs as C,telemetryOff as $}from"./chunk-
|
|
1
|
+
import{createRequire as w}from"module";const I=w(import.meta.url);import{ensureHelper as g,helperManifestDirs as C,telemetryOff as $}from"./chunk-OUCQTYIW.mjs";import{resolveEntry as x,upsertMcpConfig as v}from"./chunk-OADIHE3T.mjs";import{PACKAGE_NAME as j}from"./chunk-AHIKIVTB.mjs";import"./chunk-MTTWR4T7.mjs";import{spawnSync as l}from"child_process";import E from"fs";import y from"os";import h from"path";var s="buoy-web";var b="https://buoy.gg/buoy/latest/docs/buoy-web";var c=r=>l("/bin/sh",["-c",`command -v ${r}`],{encoding:"utf8"}).status===0;var u=(r,e)=>l(r,e,{encoding:"utf8",timeout:3e4});function W(r={}){const e=r.log??(o=>process.stdout.write(o+"\n"));const m=x({mode:r.mode,registry:r.registry},e);const t={type:"stdio",command:m.command,args:[...m.args,"web"]};const i=JSON.stringify(t);const n=[];const d=[];if(c("claude")){u("claude",["mcp","remove",s,"-s","user"]);const o=u("claude",["mcp","add-json",s,i,"-s","user"]);if(o.status===0)n.push("Claude Code: added `buoy-web` for all your projects");else d.push(`Claude Code: couldn't add it (${(o.stderr||o.stdout||"").trim().split("\n")[0]}). Run: claude mcp add-json ${s} '${i}' -s user`)}else d.push(`Claude Code not found. If you use it, run: claude mcp add-json ${s} '${i}' -s user`);const p=h.join(y.homedir(),".cursor");if(E.existsSync(p)){const o=[];v(h.join(p,"mcp.json"),"mcpServers",t,y.homedir(),o,s);n.push(...o.map(f=>/backed up/.test(f)?`Cursor: ${f}`:"Cursor: added `buoy-web` for all your projects"))}if(c("codex")){u("codex",["mcp","remove",s]);const o=u("codex",["mcp","add",s,"--",t.command,...t.args]);if(o.status===0)n.push("Codex: added `buoy-web`");else d.push(`Codex: couldn't add it. Run: codex mcp add ${s} -- ${t.command} ${t.args.join(" ")}`)}if(c("code")){const o=u("code",["--add-mcp",JSON.stringify({name:s,...t})]);if(o.status===0)n.push("VS Code: added `buoy-web`")}const a=g();if(a.length)n.push(`Chrome helper set up for ${a.length===1?"1 browser":`${a.length} browsers`}`);else if(!C().length)d.push("No Chrome-family browser found. Buoy Web will open its own Chrome window instead.");e("Buoy Web is set up:");for(const o of n)e(` \u2022 ${o}`);for(const o of d)e(` \u2022 ${o}`);e("");e("Next steps:");e(` 1. Add the Buoy Web extension to Chrome: ${b}`);e(" Without it, Buoy Web opens its own Chrome window instead of tabs in yours.");e(" 2. Restart your editor, then ask your AI to do something on the web.");e("");e("Buoy Web only works in its own tabs. It never types passwords or card numbers; you do.");if(!$()){e("It sends a small usage count once a day: a random id and its version, no web addresses or page text.");e("Turn that off with BUOY_TELEMETRY=0.")}if(t.command==="npx")e(`It runs with \`npx -y ${j}@latest web\`, so it stays up to date.`)}export{b as WEB_DOCS_URL,W as runWebInit};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{createRequire as Be}from"module";const Ze=Be(import.meta.url);import{ensureHelper as Re,extensionInstalled as Pe,findHelpers as me,noteUse as Ie,startRelay as _e,webDir as Le}from"./chunk-
|
|
1
|
+
import{createRequire as Be}from"module";const Ze=Be(import.meta.url);import{ensureHelper as Re,extensionInstalled as Pe,findHelpers as me,noteUse as Ie,startRelay as _e,webDir as Le}from"./chunk-OUCQTYIW.mjs";import"./chunk-MTTWR4T7.mjs";import{StdioServerTransport as Me}from"@modelcontextprotocol/sdk/server/stdio.js";import V from"fs";import we from"os";import q from"path";import{McpServer as We}from"@modelcontextprotocol/sdk/server/mcp.js";import{z as pe}from"zod";import{z as w}from"zod";var G=u=>new Promise(y=>setTimeout(y,u));var k=u=>({content:[{type:"text",text:u}]});var ge=u=>({content:[{type:"text",text:`Error: ${u?.message??u}`}],isError:true});var be="This is Buoy's own new tab, and it's blank. Buoy works only in its own tabs and can't see the person's other tabs. Use open with the link.";var ye="That's a password, code or card field. Buoy never types those. Stop here and don't submit the form: ask the person to type it into Buoy's tab themselves, and carry on only after they say it's done.";var ne="That would send the form with its password, code or card field still empty. Stop here: ask the person to type it into Buoy's tab, then try again after they say it's done.";var xe=[100,500];function De(u){const y=u.pageScript;const g=(...e)=>u.trace?.(e.join(" "));let f=null;let x=null;let a=null;let d=null;let B=null;let p=false;let S=null;let $=new WeakSet;let h=[];let R=[];let b=null;let K=[];let U=[];const z=()=>x?.pages().filter(e=>$.has(e)&&!e.isClosed())??[];const oe=()=>!a||a.url()==="about:blank";function ae(e){f=x=a=null;d?.close?.();d=null;B=null;$=new WeakSet;U=[];b=null;h=[];X=new WeakMap;S??=e??null}async function ke(){d=await u.connect(o=>{S=o});f=d.browser;x=d.context;B=d.mode;if(d.ownsAll)for(const o of x.pages())$.add(o);const e=f;const t=x;const n=()=>{if(x===t)ae("Chrome closed or restarted, so Buoy reconnected in a new tab. Open the link again.")};e?.on("disconnected",n);t.on("close",n);ve(t)}async function _(){if(a&&!a.isClosed())return a;if(S){const e=S;S=null;if(/Cancel/.test(e)){ae();S=null;throw new Error(e)}}if(!x)await ke();p=true;try{a=z()[0]??await x.newPage()}finally{p=false}$.add(a);return a}const re=new WeakSet;function ve(e){if(re.has(e))return;re.add(e);for(const t of e.pages())t.on("dialog",n=>$.has(t)&&ie(n));e.on("page",t=>{t.on("dialog",n=>$.has(t)&&ie(n));if(p)return;if(B==="extension"){$.add(t);h=[...h,t];return}R.push(t.opener().catch(()=>null).then(n=>{if(!n||!$.has(n))return;$.add(t);h=[...h,t]}))})}const ie=e=>{b=e;K.splice(0).forEach(t=>t())};async function j(e){let t;const n=new Promise(r=>K.push(t=r));const o=e();try{await Promise.race([o,n])}finally{K=K.filter(r=>r!==t);o.catch(()=>{})}}async function Y(e=a){g("helper: check");const t=await e.evaluate(()=>!!globalThis.__buoyWeb).catch(()=>false);if(!t){await e.evaluate(y);await e.evaluate(n=>Object.assign(globalThis.__buoyWeb.cfg,n),{dedup:true,jump:true})}}async function M(e,...t){const n=await _();if(b)throw new Error(`A browser dialog is open: "${b.message()}". Answer it with dialog first.`);await Y(n);return n.evaluate(({fn:o,args:r})=>globalThis.__buoyWeb[o](...r),{fn:e,args:t})}const J=e=>{try{return new URL(e).origin}catch{return""}};let X=new WeakMap;let $e=1;const se=e=>{let t=X.get(e);if(!t)X.set(e,t=$e++);return t};function Z(){if(!a||typeof a.mainFrame!=="function")return[];const e=a.mainFrame();return a.frames().filter(t=>{if(t===e||!/^https?:/.test(t.url()))return false;const n=t.parentFrame?.();return!!n&&J(t.url())!==J(n.url())})}async function H(e,t,...n){const o=await e.evaluate(()=>!!globalThis.__buoyWeb).catch(()=>false);if(!o){await e.evaluate(y);await e.evaluate(r=>Object.assign(globalThis.__buoyWeb.cfg,r),{dedup:true,jump:true})}return e.evaluate(({fn:r,args:i})=>globalThis.__buoyWeb[r](...i),{fn:t,args:n})}async function le(e){if(e==null)return{f:null,t:e};const t=String(e).trim();const n=/^f(\d+)(e\d+)$/.exec(t);const o=Z();if(n){const r=o.find(i=>se(i)===Number(n[1]));if(!r)throw new Error(`${e} is no longer on the page (its frame closed or changed). Read again.`);return{f:r,t:n[2]}}if(/^e\d+$/.test(t))return{f:null,t:e};if(!o.length||await M("find",e))return{f:null,t:e};for(const r of o)if(await H(r,"find",e).catch(()=>false))return{f:r,t:e};return{f:null,t:e}}async function ee(e){try{const i=await e.frameElement();await i.scrollIntoViewIfNeeded().catch(()=>{});const s=await i.boundingBox();const m=await i.evaluate(l=>{const c=l.ownerDocument.defaultView.getComputedStyle(l);return[l.clientLeft+(parseFloat(c.paddingLeft)||0),l.clientTop+(parseFloat(c.paddingTop)||0)]});if(s)return{x:s.x+m[0],y:s.y+m[1]}}catch{}const t=e.parentFrame?.();const n=t&&t!==a.mainFrame()?Z().find(i=>i===t)??null:null;const o=n?await H(n,"frameRect",e.url()):await M("frameRect",e.url());if(!o)throw new Error(`Couldn't find where the frame from ${J(e.url())} sits on the page.`);const r=n?await ee(n):{x:0,y:0};return{x:o.x+r.x,y:o.y+r.y}}async function T(e,t,...n){const{f:o,t:r}=await le(t);if(!o)return M(e,r,...n);const i=await H(o,e,r,...n);if(i&&typeof i==="object"&&typeof i.x==="number"){const s=await ee(o);i.x+=s.x;i.y+=s.y}return i}async function ce(){if(await M("submitBlocked",null))return true;for(const e of Z())if(await H(e,"submitBlocked",null).catch(()=>false))return true;return false}const E=()=>b?Promise.resolve():Promise.race([a.evaluate(([e,t])=>globalThis.__buoyWeb?.settle(e,t),xe),G(xe[1]+200)]).catch(()=>{});async function ue(){const e=z().length;return`Tab: ${await a.title().catch(()=>"")} (${a.url()})${e>1?`, ${e} tabs open`:""}
|
|
2
2
|
`}const Te=e=>e.map(t=>t.replace(/^(f\d+)?e\d+ /,""));async function Q(){const e=(await M("outline",{})).split("\n");for(const t of Z()){const n=String(await H(t,"outline",{}).catch(()=>"")).split("\n").filter(Boolean);if(n.length)e.push(`In a frame from ${J(t.url())}:`,...n.map(o=>o.replace(/^e(\d+) /,`f${se(t)}e$1 `)))}return{full:e,plain:Te(e)}}const Ce=e=>Promise.race([e.waitForLoadState("domcontentloaded"),e.evaluate(()=>{const t=globalThis;return new Promise(n=>t.document.readyState!=="loading"?n():t.addEventListener("DOMContentLoaded",()=>n(),{once:true}))}),G(5e3)]).catch(()=>{});async function A(){if(b)return`A browser dialog is open: "${b.message()}". Use dialog to answer it.`;const{full:e,plain:t}=await Q();const n=l=>l.reduce((c,v)=>c.set(v,(c.get(v)??0)+1),new Map);const o=l=>c=>(l.get(c)??0)>0?(l.set(c,(l.get(c)??0)-1),true):false;const r=o(n(U));const i=o(n(t));const s=e.filter((l,c)=>!r(t[c]));const m=[];if(s.length>12){m.push(`The page changed a lot. It now shows:
|
|
3
3
|
${e.join("\n")}`)}else{const l=U.filter(c=>!i(c)).slice(0,6);if(s.length)m.push("New on page:",...s.map(c=>`+ ${c}`));if(l.length)m.push("Gone:",...l.map(c=>`- ${c}`))}U=t;await Promise.all(R.splice(0));await d?.idle?.();if(h.length){const l=z();for(const c of h){await Ce(c);let v="";try{await Y(c);v=(await c.evaluate(()=>globalThis.__buoyWeb.outline({textOnly:true}))).split("\n").slice(0,15).join("\n")}catch{}m.push(`A new tab opened (tab ${l.indexOf(c)}, ${c.url()}). You are still on tab ${l.indexOf(a)}. It shows:
|
|
4
4
|
${v}
|
|
@@ -19,4 +19,4 @@ ${await A()}`)}const o=await P(e);await a.mouse.click(o.x,o.y);await a.keyboard.
|
|
|
19
19
|
${await A()}`)});N("dialog","Answer an open browser dialog (alert, confirm or prompt).",{accept:w.boolean(),text:w.string().optional()},async({accept:e,text:t})=>{if(!b)return k("No dialog is open.");const n=b;b=null;await(e?n.accept(t):n.dismiss());await E();return k(`Dialog ${e?"accepted":"dismissed"}.
|
|
20
20
|
${await A()}`)});N("screenshot",'A picture of the visible page (only when you need to see pixels). Its pixels match the page, so click, hover or drag can aim at a spot with target "x,y".',{},async()=>{await _();const e=await a.screenshot({type:"jpeg",quality:60,scale:"css"});return{content:[{type:"image",data:e.toString("base64"),mimeType:"image/jpeg"}]}},"read");for(const e of u.extraTools?.({page:()=>a,pointAt:P,frame:E,changes:A,target:W})??[])te.push(e);return{tools:te,labelOf:async e=>{try{const t=await T("locate",e);return t?.ok?t.label:null}catch{return null}},currentUrl:()=>a&&!a.isClosed()?a.url():null,async close(){const e=d;d?.close?.();if(e?.mode==="window")await x?.close().catch(()=>{})}}}var Fe='// Buoy Web page helper (installed into the page by the MCP server).\n// outline(): visible text and controls, each control with a short ref.\n// locate(target): a control\'s on-screen center by ref or visible name.\n(() => {\n if (globalThis.__buoyWeb) return;\n const cfg = {}; // switches from the server (Buoy Optimize)\n // A new id every time the helper is installed (a new page), so old refs are never reused.\n const pageId = Math.random().toString(36).slice(2, 10);\n const refs = new Map(); // ref -> element\n const refNames = new Map(); // ref -> the name it had when the agent saw it\n const ids = new WeakMap(); // element -> ref\n // Refs start at a new range on each page load in a tab (e1\u2026, then e2000\u2026, e3000\u2026), so a ref\n // read before a reload never matches something on the new page.\n let next = 1;\n try {\n const n = (Number(sessionStorage.getItem("__buoyRefBase")) || 0) + 1;\n sessionStorage.setItem("__buoyRefBase", String(n));\n if (n > 1) next = n * 1000;\n } catch {\n next = (1 + Math.floor(Math.random() * 900)) * 1000;\n }\n const refOf = (el) => {\n let r = ids.get(el);\n if (!r) {\n r = `e${next++}`;\n ids.set(el, r);\n }\n refs.set(r, el);\n return r;\n };\n const CONTROL = "a[href],button,input,select,textarea,summary,[role=button],[role=link],[role=tab],[role=menuitem],[role=option],[role=checkbox],[role=radio],[role=switch],[role=slider],[role=listitem][tabindex],[role=textbox],[contenteditable=\'\'],[contenteditable=true],[tabindex]:not([tabindex=\'-1\'])";\n // Elements a page made clickable or hoverable in script (el.onclick = ..., onmouseenter\n // on a chart bar), with no control role: calendar cells, chart bars, map pins.\n const ACTIVE = ["onclick", "onmousedown", "onpointerdown", "onmouseenter", "onmouseover", "onpointerenter", "ondblclick"];\n const active = (el) => el !== document.body && el !== document.documentElement && ACTIVE.some((k) => typeof el[k] === "function");\n let rankCache = new WeakMap(); // parent -> where each child sits among same-tag siblings\n const isCtl = (el) => el.matches(CONTROL) || (el.tagName === "LI" && !!el.closest("[role=listbox]")) || active(el);\n const hidden = (el) => {\n for (let n = el; n && n.nodeType === 1; n = n.parentElement || n.getRootNode()?.host) {\n if (n.hidden || n.getAttribute?.("aria-hidden") === "true") return true;\n const s = (n.ownerDocument?.defaultView ?? window).getComputedStyle(n);\n if (s.display === "none" || s.visibility === "hidden") return true;\n }\n return false;\n };\n // Match names loosely: case, spaces and number commas don\'t matter ("Row 9,040" = "row 9040").\n const norm = (x) => String(x).toLowerCase().replace(/(\\d),(?=\\d{3}\\b)/g, "$1").replace(/\\s+/g, " ").trim();\n const nameOf = (el) => {\n const a = el.getAttribute("aria-label");\n if (a) return a.trim();\n if (el.labels?.length)\n return [...el.labels].map((l) => {\n // A label that wraps its field would also pull in the field\'s own text (a select\'s options).\n const c = l.cloneNode(true);\n c.querySelectorAll("select,option,datalist,textarea,input").forEach((n) => n.remove());\n return c.textContent.replace(/\\s+/g, " ").trim();\n }).join(" ");\n if (el.tagName === "INPUT" && el.type === "button") return el.value;\n const t = (el.innerText ?? el.textContent ?? "").replace(/\\s+/g, " ").trim();\n const n = t || el.getAttribute("title") || el.getAttribute("placeholder") || el.getAttribute("name") || "";\n if (n || !active(el)) return n;\n // A shape with no name (a chart bar): "rect 3 of 7", by its place among its siblings.\n // Ranks are worked out once per parent, so a chart with thousands of shapes stays fast.\n const par = el.parentNode;\n let ranks = rankCache.get(par);\n if (!ranks) {\n ranks = new Map();\n const count = {};\n for (const x of par?.children ?? []) ranks.set(x, (count[x.tagName] = (count[x.tagName] ?? 0) + 1));\n ranks.total = count;\n rankCache.set(par, ranks);\n }\n return `${el.tagName.toLowerCase()} ${ranks.get(el)} of ${ranks.total[el.tagName]}`;\n };\n const roleOf = (el) => {\n const r = el.getAttribute("role");\n if (r) return r;\n const t = el.tagName.toLowerCase();\n if (t === "a") return "link";\n if (t === "input") return { checkbox: "checkbox", radio: "radio", range: "slider", button: "button", submit: "button", file: "file", date: "date", time: "time", "datetime-local": "date-time", month: "month", number: "number" }[el.type] ?? "textbox";\n if (t === "select") return "select";\n if (t === "textarea") return "textbox";\n if (t === "button") return "button";\n if (active(el) && !el.matches(CONTROL)) return ACTIVE.slice(0, 3).some((k) => typeof el[k] === "function") || typeof el.ondblclick === "function" ? "clickable" : "hover-area";\n return "control";\n };\n // A rich text box\'s text as plain lines (" / " between them), plus its formatting listed\n // apart, so typed asterisks never look like bold.\n function richText(root) {\n const lines = [];\n let line = "";\n const flush = () => { if (line.trim()) lines.push(line.trim()); line = ""; };\n const go = (n) => {\n if (n.nodeType === 3) { line += n.textContent.replace(/\\s+/g, " "); return; }\n if (n.nodeType !== 1) return;\n if (n.tagName === "BR") return flush();\n const block = /^(DIV|P|LI|UL|OL|H\\d|BLOCKQUOTE)$/.test(n.tagName);\n if (block) flush();\n for (const c of n.childNodes) go(c);\n if (block) flush();\n };\n for (const c of root.childNodes) go(c);\n flush();\n const q = (els) => [...new Set([...els].map((e) => e.innerText.replace(/\\s+/g, " ").trim()).filter(Boolean))].map((t) => `"${t.slice(0, 40).replace(/"/g, "\'")}"`).join(", ");\n const parts = [`text="${lines.join(" / ").replace(/"/g, "\'").slice(0, 300)}"`];\n const bold = q(root.querySelectorAll("b,strong")), it = q(root.querySelectorAll("i,em"));\n if (bold) parts.push(`bold: ${bold}`);\n if (it) parts.push(`italic: ${it}`);\n const lis = root.querySelectorAll("li").length;\n if (lis) parts.push(`list items: ${lis}`);\n return parts.join(" ");\n }\n // Walk the page in order, into open shadow roots and same-origin iframes.\n function walk(root, offset, out) {\n const nodes = root.querySelectorAll ? root.querySelectorAll("*") : [];\n for (const el of nodes) {\n if (el.shadowRoot) walk(el.shadowRoot, offset, out);\n if (el.tagName === "IFRAME") {\n try {\n const d = el.contentDocument;\n const r = el.getBoundingClientRect();\n if (d) walk(d, { x: offset.x + r.x, y: offset.y + r.y }, out);\n } catch { /* best effort: nothing to do */ }\n }\n out.push({ el, offset });\n }\n return out;\n }\n // Tags that sit inside a sentence. Their text is read as part of it.\n const INLINE = new Set(["B", "STRONG", "I", "EM", "SPAN", "CODE", "SMALL", "SUP", "SUB", "MARK", "ABBR", "CITE", "Q", "U", "S", "TIME", "KBD", "VAR", "DFN", "BDI", "FONT", "BR", "WBR"]);\n // A sentence with links in it, links kept in place: "[The Demi-Virgin](e23) is a play by [Avery Hopwood](e24)".\n // Without this the sentence reads "is a play by ." and the links come after it, out of order.\n // Returns null (read it the old way) if the element holds anything but text, inline tags and links.\n function sentence(el, textOnly) {\n let out = "";\n const parts = [];\n const visit = (node) => {\n for (const n of node.childNodes) {\n if (n.nodeType === 3) { out += n.textContent; continue; }\n if (n.nodeType !== 1 || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(n.tagName)) continue;\n if (n.tagName === "A" && isCtl(n) && !n.querySelector(CONTROL)) {\n if (hidden(n)) { parts.push(n); continue; }\n const text = (n.textContent || "").replace(/\\s+/g, " ").trim();\n const name = (text || nameOf(n)).slice(0, 80);\n out += textOnly ? name : `[${name}](${refOf(n)})`;\n parts.push(n, { link: n, name });\n continue;\n }\n if (n.tagName === "BR") { out += " "; continue; }\n if (!INLINE.has(n.tagName) || isCtl(n) || n.shadowRoot) return false;\n parts.push(n);\n if (visit(n) === false) return false;\n }\n };\n if (visit(el) === false || !parts.some((x) => x.link)) return null;\n for (const x of parts) {\n if (!x.link) { used.add(x); continue; }\n if (!seen.has(x.link)) seen.set(x.link, sig(x.link));\n if (!textOnly) refNames.set(refOf(x.link), x.name);\n }\n return out.replace(/\\s+/g, " ").trim().slice(0, 500);\n }\n let used = new WeakSet(); // inline tags and links already read as part of a sentence\n function outline({ textOnly = false } = {}) {\n rankCache = new WeakMap(); // the page may have changed since the last read\n used = new WeakSet();\n const lines = [];\n let lastText = "";\n for (const { el } of walk(document, { x: 0, y: 0 }, [])) {\n if (el?.nodeType !== 1 || used.has(el) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION"].includes(el.tagName)) continue;\n const isControl = isCtl(el);\n if (isControl) {\n if (hidden(el)) continue;\n const name = nameOf(el).slice(0, 80);\n const role = roleOf(el);\n const extra = [];\n if (el.disabled) extra.push("disabled");\n if (["date", "time", "date-time", "month", "number"].includes(role) && el.value) extra.push(`value="${el.value}"`);\n if (role === "textbox" && el.value) extra.push(el.type === "password" ? "filled" : `value="${String(el.value).slice(0, 40)}"`);\n // A rich text box has no value: show its text, with bold, italic and list items marked.\n if (el.isContentEditable && !el.parentElement?.isContentEditable) extra.push(richText(el));\n if (role === "select") {\n // Every option up to 40, so a long list (time zones, countries) never hides the one asked for.\n const opts = [...el.options].map((o) => o.text.trim());\n extra.push(`value="${el.options[el.selectedIndex]?.text ?? ""}"`, `options: ${opts.slice(0, 40).join(" | ")}${opts.length > 40 ? ` | \u2026and ${opts.length - 40} more (select picks any by name)` : ""}`);\n }\n if (role === "checkbox" || role === "radio") extra.push(el.checked ? "checked" : "unchecked");\n if (role === "slider") extra.push(`value=${el.value}`);\n if (el.getAttribute("aria-selected") === "true") extra.push("selected");\n if (!seen.has(el)) seen.set(el, sig(el)); // the first look: "changes" means changes from how it first looked\n if (!textOnly) refNames.set(refOf(el), name);\n lastText = "";\n lines.push(textOnly ? `${role} "${name}"${extra.length ? " " + extra.join(" ") : ""}` : `${refOf(el)} ${role} "${name}"${extra.length ? " " + extra.join(" ") : ""}`);\n continue;\n }\n // Plain text: only an element\'s own direct text. A repeat is dropped only right after itself,\n // since lists repeat lines that matter ("Not available for these dates" under each cabin).\n let own = "";\n for (const n of el.childNodes) if (n.nodeType === 3) own += n.textContent;\n own = own.replace(/\\s+/g, " ").trim();\n if (!own || el.closest?.(CONTROL) || (el.parentElement && active(el.parentElement)) || own === lastText || hidden(el)) continue;\n if (cfg.dedup && el.tagName === "LABEL" && el.control) { lastText = own; continue; } // the field line carries this name\n const line = sentence(el, textOnly) ?? own.slice(0, 200);\n lastText = own;\n lines.push(line);\n }\n return lines.join("\\n");\n }\n // A watched element: click it the moment its look or name changes.\n let watched = null;\n let watchedAt = null;\n // If the page draws the watched element again, follow whatever now sits in its spot.\n const rewatch = () => {\n if (watched?.isConnected || !watchedAt) return;\n let n = document.elementFromPoint(...watchedAt);\n while (n && n.nodeType === 1 && !isCtl(n)) n = n.parentElement;\n watched = n && n.nodeType === 1 ? n : null;\n };\n const seen = new WeakMap(); // element -> its look when the agent last read the page\n const sig = (el) => [nameOf(el), el.getAttribute("class"), el.getAttribute("aria-pressed"), el.getAttribute("aria-checked"), el.disabled].join("|");\n function watch(target) {\n // "Changed" means changed from what the agent last saw, so a change that\n // already happened while it was thinking counts at once.\n watched = find(target);\n if (watched) { const r = watched.getBoundingClientRect(); watchedAt = [r.x + r.width / 2, r.y + r.height / 2]; }\n return watched ? (seen.get(watched) ?? sig(watched)) : null;\n }\n function waitSig(base, changed, ms, point) {\n return until(() => {\n rewatch();\n if (!watched?.isConnected) return false;\n if ((sig(watched) !== base) !== changed) return false;\n if (!point) return true;\n const r = locate("@watched");\n return { ...r, seenAt: Math.round(performance.now()), now: sig(watched), base };\n }, ms);\n }\n function find(target) {\n const t = String(target).trim();\n if (t === "@watched") { rewatch(); return watched?.isConnected ? watched : null; }\n if (/^e\\d+$/.test(t) && refs.has(t)) {\n const el = refs.get(t);\n if (el.isConnected && !hidden(el)) return el;\n refs.delete(t);\n // The page drew it again (a calendar, a list that re-renders): find it by the name it had,\n // but only when exactly one visible control has that name, so a ref never jumps to a twin.\n const old = refNames.get(t);\n if (old) {\n const same = walk(document, { x: 0, y: 0 }, []).map((x) => x.el).filter((e) => e?.nodeType === 1 && isCtl(e) && !hidden(e) && norm(nameOf(e)) === norm(old));\n if (same.length === 1) {\n refs.set(t, same[0]); // later lookups in the same action find it too\n return same[0];\n }\n }\n return null;\n }\n const all = walk(document, { x: 0, y: 0 }, []).map((x) => x.el).filter((el) => el?.nodeType === 1 && isCtl(el) && !hidden(el));\n const want = norm(t);\n let ctl;\n if (cfg.rank) {\n // RANK: exact, then starts with, then contains; on screen before off screen.\n const onScreen = (el) => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; };\n const tests = [(n) => n === want, (n) => n.startsWith(want), (n) => n.includes(want)];\n for (const test of tests) {\n const hits = all.filter((el) => test(norm(nameOf(el))));\n if (hits.length) { ctl = hits.find(onScreen) ?? hits[0]; break; }\n }\n } else ctl = all.find((el) => norm(nameOf(el)) === want) ?? all.find((el) => norm(nameOf(el)).includes(want));\n if (ctl) return ctl;\n // Not a control: any visible element whose text matches (bins, cards,\n // menu rows drawn as plain boxes). The smallest match is the most exact.\n const texts = walk(document, { x: 0, y: 0 }, []).map((x) => x.el).filter((el) => el?.nodeType === 1 && !["SCRIPT", "STYLE", "HTML", "BODY", "HEAD"].includes(el.tagName));\n const txt = (el) => norm(el.innerText ?? "");\n const area = (el) => { const r = el.getBoundingClientRect(); return r.width * r.height; };\n for (const match of [(el) => txt(el) === want, (el) => txt(el).includes(want) && txt(el).length <= want.length + 16]) {\n const hits = texts.filter((el) => match(el) && area(el) > 0 && !hidden(el));\n if (hits.length) return hits.sort((a, b) => area(a) - area(b))[0];\n }\n return null;\n }\n // From a frame\'s coordinates up to this helper\'s own window. A frame on another site\n // has no frameElement here; Buoy adds that frame\'s offset itself.\n function toTop(w, x, y) {\n for (; w && w !== window; w = w.parent) {\n let fe = null;\n try { fe = w.frameElement; } catch { /* best effort: nothing to do */ }\n if (!fe) break;\n const fr = fe.getBoundingClientRect();\n x += fr.x;\n y += fr.y;\n }\n return { x, y };\n }\n // Where a frame from another site sits on this page (its content box), found by its\n // address, after scrolling it into view. Buoy adds this to spots found inside it.\n function frameRect(url) {\n const same = (a, b) => { try { return new URL(a, location.href).href === new URL(b).href; } catch { return false; } };\n const origin = (a) => { try { return new URL(a, location.href).origin; } catch { return ""; } };\n const frames = walk(document, { x: 0, y: 0 }, []).map((x) => x.el).filter((el) => el.tagName === "IFRAME");\n const el = frames.find((f) => same(f.src, url)) ?? frames.find((f) => origin(f.src) === origin(url));\n if (!el) return null;\n el.scrollIntoView({ block: "nearest", inline: "nearest" });\n const r = el.getBoundingClientRect();\n const st = getComputedStyle(el);\n const pad = (k) => parseFloat(st[k]) || 0;\n return toTop(el.ownerDocument.defaultView, r.x + el.clientLeft + pad("paddingLeft"), r.y + el.clientTop + pad("paddingTop"));\n }\n // The element\'s center in the top page\'s viewport, after scrolling it into view.\n function locate(target) {\n const el = find(target);\n if (!el) return { ok: false, error: /^e\\d+$/.test(String(target).trim()) ? `${target} is no longer on the page (it was removed or drawn again). Use its name, or read again.` : `Nothing visible matches "${target}". Read the page to see what is there.` };\n el.scrollIntoView({ block: "center", inline: "center" });\n let r = el.getBoundingClientRect();\n let x = r.x + r.width / 2, y = r.y + r.height / 2;\n ({ x, y } = toTop(el.ownerDocument.defaultView, x, y));\n return { ok: true, x, y, label: isCtl(el) ? `${roleOf(el)} "${nameOf(el).slice(0, 40)}"` : `"${(el.innerText ?? "").replace(/\\s+/g, " ").trim().slice(0, 40)}"` };\n }\n // Wait until check() returns something truthy. Page-change events fire at\n // once even when the window is behind others (paint callbacks get slowed\n // down there), with a short timer as a backstop.\n function until(check, ms) {\n return new Promise((res) => {\n const t0 = performance.now();\n let done = false, timer = 0;\n const mo = new MutationObserver(() => run());\n const finish = (v) => { done = true; mo.disconnect(); clearTimeout(timer); res(v); };\n const run = () => {\n if (done) return;\n const v = check();\n if (v) return finish(v);\n if (performance.now() - t0 > ms) return finish(false);\n };\n const poll = () => { run(); if (!done) timer = setTimeout(poll, 16); };\n mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });\n poll();\n });\n }\n const ready = (el, when) => !!el && (when !== "enabled" || (!el.disabled && el.getAttribute("aria-disabled") !== "true"));\n // Resolve the frame a control with this name shows (or turns on).\n function waitFor(target, when, ms, point) {\n return until(() => ready(find(target), when) && (point ? locate(target) : true), ms);\n }\n function waitGone(target, ms) {\n return until(() => !find(target), ms).then((v) => !!v);\n }\n // Scroll the page and every scroll box until the text is drawn and visible.\n async function scrollTo(text, ms) {\n const t0 = performance.now();\n const has = () => find(text); // the same matching as click: controls by name, then short text\n const boxes = () => [document.scrollingElement, ...[...document.querySelectorAll("*")].filter((e) => e.scrollHeight > e.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(e).overflowY))];\n // JUMP: in a numbered list ("Row 7,342"), read the numbers on screen and\n // jump near the target, then fine-tune, instead of a screen at a time.\n const num = (x) => Number(String(x).replace(/,/g, ""));\n const want = String(text).match(/^(.*?)([\\d,]+)\\s*$/);\n if (cfg.jump && want && !has()) {\n const prefix = want[1].trim().toLowerCase(), N = num(want[2]);\n for (let k = 0; k < 10 && !has(); k++) {\n let moved = false;\n for (const b of boxes()) {\n const br = b === document.scrollingElement ? { top: 0, bottom: innerHeight } : b.getBoundingClientRect();\n const seen = [];\n for (const el of b.querySelectorAll("*")) {\n if (el.children.length) continue;\n const m = (el.textContent || "").trim().match(/^(.*?)([\\d,]+)\\s*$/);\n if (!m || m[1].trim().toLowerCase() !== prefix) continue;\n const r = el.getBoundingClientRect();\n if (r.bottom < br.top || r.top > br.bottom || !r.height) continue;\n seen.push([num(m[2]), r.top]);\n }\n if (seen.length < 2) continue;\n seen.sort((a, c) => a[0] - c[0]);\n const [n0, y0] = seen[0], [n1, y1] = seen[seen.length - 1];\n if (n1 === n0) continue;\n const pxPer = (y1 - y0) / (n1 - n0);\n const delta = (N - (n0 + n1) / 2) * pxPer;\n if (Math.abs(delta) < 2) continue;\n b.scrollTop += delta;\n moved = true;\n }\n if (!moved) break;\n await new Promise((r) => setTimeout(r, 40));\n }\n }\n while (performance.now() - t0 < ms) {\n const el = has();\n if (el) {\n el.scrollIntoView({ block: "center" });\n return { ok: true };\n }\n let moved = false;\n for (const b of boxes()) {\n if (b.scrollTop + b.clientHeight < b.scrollHeight - 2) {\n b.scrollTop += Math.max(200, b.clientHeight * 0.9);\n moved = true;\n }\n }\n await new Promise((r) => setTimeout(r, moved ? 30 : 250));\n if (!moved && !has()) {\n // Nothing left to scroll here; a feed may still be loading more.\n if (performance.now() - t0 > ms / 2) break;\n }\n }\n return { ok: !!has(), error: `"${text}" did not show after scrolling.` };\n }\n // Resolve once the page has gone quiet (no DOM change for `quiet` ms), or at `max`.\n function settle(quiet, max) {\n return new Promise((res) => {\n const t0 = performance.now();\n let last = t0, timer = 0;\n const mo = new MutationObserver(() => (last = performance.now()));\n mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });\n const tick = () => {\n const now = performance.now();\n if (now - last >= quiet || now - t0 >= max) { mo.disconnect(); return res(Math.round(now - t0)); }\n timer = setTimeout(tick, 10);\n };\n tick();\n });\n }\n // For a native date field: parse the text and give back the digits in the\n // order this browser\'s field wants (e.g. MM DD YYYY), plus the field\'s left edge.\n function dateKeys(target, text) {\n const el = find(target);\n if (!el || el.tagName !== "INPUT" || el.type !== "date") return null;\n const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];\n let y, m, d, t = String(text).trim();\n let k;\n if ((k = t.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})$/))) [, y, m, d] = k;\n else if ((k = t.match(/^(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{4})$/))) [, m, d, y] = k;\n else if ((k = t.match(/^([A-Za-z]+)\\.? (\\d{1,2}),? (\\d{4})$/))) { m = months.indexOf(k[1].slice(0, 3).toLowerCase()) + 1; d = k[2]; y = k[3]; }\n else if ((k = t.match(/^(\\d{1,2}) ([A-Za-z]+)\\.? (\\d{4})$/))) { d = k[1]; m = months.indexOf(k[2].slice(0, 3).toLowerCase()) + 1; y = k[3]; }\n if (!y || !m || !d || m < 1) return null;\n const parts = new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date(2001, 1, 3)).map((p) => p.type).filter((x) => ["year", "month", "day"].includes(x));\n const val = { year: String(y).padStart(4, "0"), month: String(m).padStart(2, "0"), day: String(d).padStart(2, "0") };\n el.scrollIntoView({ block: "center" });\n const r = el.getBoundingClientRect();\n const { x: px, y: py } = toTop(el.ownerDocument.defaultView, r.x + 10, r.y + r.height / 2);\n return { keys: parts.map((p) => val[p]).join(""), x: px, y: py, want: `${val.year}-${val.month}-${val.day}` };\n }\n // A slider\'s value (native range or ARIA slider), or null if the target isn\'t a slider.\n function sliderOf(target) {\n const el = find(target);\n if (!el) return null;\n if (el.tagName === "INPUT" && el.type === "range") return { v: Number(el.value), min: Number(el.min || 0), max: Number(el.max || 100) };\n if (el.getAttribute("role") === "slider") return { v: Number(el.getAttribute("aria-valuenow")), min: Number(el.getAttribute("aria-valuemin") ?? 0), max: Number(el.getAttribute("aria-valuemax") ?? 100) };\n return null;\n }\n // INPUTSCROLL: plan one scroll step toward the text; the server does it with the real wheel.\n function scrollPlan(text) {\n const el = find(text);\n if (el) {\n const r = el.getBoundingClientRect();\n const inView = r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth;\n return { found: true, inView };\n }\n // Inner scroll boxes first (deepest last in document order), the page itself last.\n const inner = [...document.querySelectorAll("*")].filter((e) => e.scrollHeight > e.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).reverse();\n const boxes = [...inner, document.scrollingElement];\n const num = (x) => Number(String(x).replace(/,/g, ""));\n const want = String(text).match(/^(.*?)([\\d,]+)\\s*$/);\n for (const b of boxes) {\n const top = b === document.scrollingElement;\n const br = top ? { top: 0, bottom: innerHeight, left: 0, right: innerWidth } : b.getBoundingClientRect();\n const cx = Math.max(0, Math.min(innerWidth - 1, (br.left + br.right) / 2)), cy = Math.max(0, Math.min(innerHeight - 1, (br.top + br.bottom) / 2));\n if (want && cfg.jump) {\n const prefix = want[1].trim().toLowerCase(), N = num(want[2]);\n const seen = [];\n for (const e of b.querySelectorAll("*")) {\n if (e.children.length) continue;\n const m = (e.textContent || "").trim().match(/^(.*?)([\\d,]+)\\s*$/);\n if (!m || m[1].trim().toLowerCase() !== prefix) continue;\n const r = e.getBoundingClientRect();\n if (r.bottom < br.top || r.top > br.bottom || !r.height) continue;\n seen.push([num(m[2]), r.top]);\n }\n if (seen.length >= 2) {\n seen.sort((a, c) => a[0] - c[0]);\n const [n0, y0] = seen[0], [n1, y1] = seen[seen.length - 1];\n if (n1 !== n0) {\n const dy = (N - (n0 + n1) / 2) * ((y1 - y0) / (n1 - n0));\n if (Math.abs(dy) >= 2) return { x: cx, y: cy, dy: Math.round(dy) };\n }\n }\n }\n if (b.scrollTop + b.clientHeight < b.scrollHeight - 2) return { x: cx, y: cy, dy: Math.round(Math.max(200, b.clientHeight * 0.9)) };\n }\n return { wait: true }; // nothing left to scroll; a feed may still load more\n }\n // Fields we never type into: passwords, one-time codes and card numbers. The person does those.\n // autocomplete can hold several words ("billing cc-number", "section-login current-password").\n const SECRET = /^(current-password|new-password|one-time-code|cc-number|cc-csc|cc-exp|cc-exp-month|cc-exp-year)$/;\n const isSecret = (el) => !!el && el.tagName === "INPUT" && (el.type === "password" || String(el.autocomplete || "").toLowerCase().split(/\\s+/).some((t) => SECRET.test(t)));\n function focused() {\n let a = document.activeElement;\n for (;;) {\n if (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;\n else if (a?.tagName === "IFRAME") {\n try { a = a.contentDocument?.activeElement ?? null; } catch { return a; }\n if (!a) return null;\n } else return a;\n }\n }\n const secret = (t) => (t == null ? isSecret(focused()) : isSecret(find(t)));\n // Would this click (or Enter in the focused field) send a form whose password, code or\n // card field is still empty? Buoy refuses: the person types those first.\n const emptySecretIn = (form) => !!form && [...form.querySelectorAll("input")].some((i) => isSecret(i) && !i.value && !hidden(i));\n const isSubmit = (el) => !!el && ((el.tagName === "BUTTON" && (!el.type || el.type === "submit")) || (el.tagName === "INPUT" && (el.type === "submit" || el.type === "image")));\n // The same check for a click at a spot: what\'s there, and is it a send button for such a form?\n function submitBlockedAt(x, y) {\n let el = document.elementFromPoint(x, y);\n while (el?.tagName === "IFRAME") {\n try {\n const r = el.getBoundingClientRect();\n x -= r.x; y -= r.y;\n el = el.contentDocument.elementFromPoint(x, y);\n } catch { break; }\n }\n while (el?.shadowRoot) { const inner = el.shadowRoot.elementFromPoint(x, y); if (!inner || inner === el) break; el = inner; }\n const btn = el?.closest?.("button, input") ?? el;\n return isSubmit(btn) && emptySecretIn(btn.form ?? btn.closest("form"));\n }\n function submitBlocked(t) {\n if (t == null) {\n const f = focused();\n return !!f && (f.tagName === "INPUT" || isSubmit(f)) && emptySecretIn(f.form ?? f.closest?.("form"));\n }\n const el = find(t);\n const btn = el?.closest?.("button, input") ?? el;\n return isSubmit(btn) && emptySecretIn(btn.form ?? btn.closest("form"));\n }\n // Is the target a rich text box (contenteditable), and is the cursor already in it?\n function editInfo(t) {\n const el = find(t);\n if (!el) return null;\n const box = el.isContentEditable ? el : null;\n if (!box) return { rich: false };\n const f = focused();\n return { rich: true, focused: !!f && (f === box || box.contains(f)) };\n }\n globalThis.__buoyWeb = { pageId, submitBlockedAt, editInfo, frameRect, scrollPlan, secret, submitBlocked, sliderOf, dateKeys, valueOf: (t) => find(t)?.value ?? null, tagOf: (t) => find(t)?.tagName ?? null, cfg, settle, outline, locate, find: (t) => !!find(t), waitFor, waitGone, scrollTo, watch, waitSig };\n})();\n';var Ue=u=>new Promise(y=>setTimeout(y,u));function je(){const u=process.env.BUOY_WEB_CDP;if(u!=="auto")return u;const y=q.join(we.homedir(),"Library/Application Support/Google/Chrome/DevToolsActivePort");if(!V.existsSync(y))throw new Error("Chrome isn't open for tools yet. In Chrome, open chrome://inspect/#remote-debugging and turn it on.");const[g,f]=V.readFileSync(y,"utf8").trim().split("\n");return`ws://127.0.0.1:${g}${f}`}var qe=()=>V.existsSync(q.join(Le(),"seen"));async function He(u,y){const{chromium:g}=await u();let f=!process.env.BUOY_WEB_PROFILE?me()[0]?.sock:void 0;if(!f&&!process.env.BUOY_WEB_PROFILE&&!process.env.BUOY_WEB_CDP&&Pe()){for(let d=0;d<40&&!f;d++){await Ue(200);f=me()[0]?.sock}}if(process.env.BUOY_WEB_CDP){const d=await g.connectOverCDP(je());return{browser:d,context:d.contexts()[0],mode:"cdp"}}if(f){const d=await _e({sock:f});d.onDetach=({reason:p})=>{if(p==="canceled_by_user")y("The person clicked Cancel on Chrome's debugging bar, so Buoy stopped. Ask them before you try again.")};const B=await g.connectOverCDP(d.url);return{browser:B,context:B.contexts()[0],mode:"extension",idle:d.idle,close:d.close}}if(!process.env.BUOY_WEB_PROFILE&&qe()){throw new Error("Chrome is closed, or the Buoy Web extension is turned off. Ask the person to open Chrome, then try again.")}const x=process.env.BUOY_WEB_PROFILE??process.env.BUOY_WEB_WINDOW_DIR??q.join(we.homedir(),".buoy","web-profile");const a=await g.launchPersistentContext(x,{headless:false,channel:"chrome",viewport:{width:1280,height:900},args:["--no-first-run","--no-default-browser-check"]});return{browser:a.browser(),context:a,mode:"window",ownsAll:true}}var Ye=/(^|[/\\])(\.env(\..*)?|\.npmrc|\.netrc|\.pgpass|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|.*\.(pem|key|p12|pfx|keychain|kdbx))$|[/\\]\.(ssh|aws|gnupg|kube|docker)[/\\]/i;function Ve(u){const y=[process.cwd(),...(process.env.BUOY_WEB_UPLOAD_DIRS??"").split(q.delimiter).filter(Boolean)].map(f=>V.realpathSync(f));let g;try{g=V.realpathSync(q.resolve(u))}catch{throw new Error(`No file at ${u}.`)}if(!y.some(f=>g===f||g.startsWith(f+q.sep)))throw new Error(`Buoy only uploads files inside the project folder (${y[0]}). ${u} is outside it.`);if(Ye.test(g))throw new Error(`${q.basename(g)} looks like a secret (keys, passwords or tokens). Buoy doesn't upload those.`);return g}var Ge=u=>({name:"upload",description:"Pick files in a file field (the real file picker, filled for you). Paths must be inside the project folder; leave empty to send a small test file.",shape:{target:u.target,paths:pe.array(pe.string()).optional()},effect:"write",async run({target:y,paths:g}){const f=g?.length?g.map(Ve):null;const x=await u.pointAt(y);const a=u.page();const[d]=await Promise.all([a.waitForEvent("filechooser",{timeout:5e3}),a.mouse.click(x.x,x.y)]);await d.setFiles(f??[{name:"test.txt",mimeType:"text/plain",buffer:Buffer.from("hello from Buoy\n")}]);await u.frame();return k(`Picked ${f?.length??1} file(s) in ${x.label}.
|
|
21
21
|
${await u.changes()}`)}});function Ke(u={}){const y=u.loadPlaywright??(()=>import("playwright-core"));const g=Date.now();const f=De({connect:p=>He(y,p),pageScript:Fe,isMac:process.platform==="darwin",trace:process.env.BUOY_WEB_TRACE==="1"?p=>process.stderr.write(`[buoy-web ${Date.now()-g}ms] ${p}
|
|
22
|
-
`):void 0,extraTools:p=>[Ge(p)]});const x=new We({name:"buoy-web",version:"7.0.
|
|
22
|
+
`):void 0,extraTools:p=>[Ge(p)]});const x=new We({name:"buoy-web",version:"7.0.73"});const a=Number(process.env.BUOY_WEB_CALL_LIMIT_MS??6e4);const d=["open","read","click","type","key","wait","scroll","drag","hover","upload","tabs","select","dialog","screenshot"];const B=[...f.tools].sort((p,S)=>d.indexOf(p.name)-d.indexOf(S.name));for(const p of B){x.tool(p.name,p.description,p.shape,async S=>{let $;const h=new Promise(b=>{$=setTimeout(()=>b(ge(new Error(`This call took over ${a/1e3} s, so Buoy stopped waiting. The page may be stuck or still loading. Try read, or open the link again.`))),a)});const R=await Promise.race([p.run(S).catch(ge),h]).finally(()=>clearTimeout($));Ie();return R})}return{server:x,close:f.close}}async function ze(){Re();const{server:u,close:y}=Ke();const g=new Me;const f=g;const x=f.send.bind(f);f.send=(h,...R)=>{if(h?.result?.tools)for(const b of h.result.tools)b._meta={...b._meta??{},"anthropic/alwaysLoad":true};return x(h,...R)};let a=false;const d=()=>{if(a)return;a=true;setTimeout(()=>process.exit(0),1500).unref();void y().finally(()=>process.exit(0))};process.stdin.once("end",d);process.stdin.once("close",d);process.on("SIGINT",d);process.on("SIGTERM",d);process.on("SIGHUP",d);await u.connect(g);const B=f.onmessage;const p=new Map;let S=Promise.resolve();f.onmessage=(h,R)=>{if(h?.method==="notifications/cancelled")p.get(h.params?.requestId)?.();if(h?.method!=="tools/call")return B(h,R);S=S.then(()=>new Promise(b=>{p.set(h.id,()=>{p.delete(h.id);b()});B(h,R)}))};const $=f.send.bind(f);f.send=(h,...R)=>{const b=$(h,...R);if(h?.id!=null&&("result"in h||"error"in h))p.get(h.id)?.();return b}}export{ze as runWebServer};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createRequire as r}from"module";const t=r(import.meta.url);import{farmFairness as e,registerSimFarmTools as m,runFarmd as o}from"./chunk-
|
|
1
|
+
import{createRequire as r}from"module";const t=r(import.meta.url);import{farmFairness as e,registerSimFarmTools as m,runFarmd as o}from"./chunk-DZRRLTOD.mjs";import"./chunk-MJDNVDJI.mjs";import"./chunk-OUCB2Y5J.mjs";import"./chunk-FOMLVT7K.mjs";import"./chunk-MTTWR4T7.mjs";export{e as farmFairness,m as registerSimFarmTools,o as runFarmd};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createRequire as e}from"module";const fe=e(import.meta.url);import{ALWAYS_ENABLED as a,ANDROID_ADB_PORT as r,BUILT_IN_ANDROID_PHONES as o,BUILT_IN_PHONES as i,DEFAULT_ANDROID_PHONE as t,DEFAULT_PHONE as n,DEFAULT_SETTINGS as m,EmulatorHost as d,Engine as l,FarmClient as s,FarmError as u,LoadMeter as E,LocalDylibBuilder as L,PHONE_OFF as p,PROTOCOL_VERSION as f,SLIM_CATEGORIES as _,STATE_VERSION as A,SimctlHost as D,Store as O,agentLabel as c,androidHelp as h,androidHome as F,bootEnv as I,buildDylib as T,classifyBootLog as N,connectFarm as S,copyCostGB as P,crashLoop as H,defaultCapMB as R,defaultSeedDir as b,detectAgent as B,eventsFilePath as C,farmAdbTarget as y,farmAdbTargets as g,farmDefaults as v,farmHelp as U,farmHome as G,farmOwnedUdids as q,findDataContainer as x,findExampleBuild as M,findRepoRoot as V,joinInsertList as w,launchFilePath as j,loadAndroidPhones as z,loadPhones as W,measurePhone as Y,nodeLauncher as k,parseCpuTime as J,parseEvents as K,parseGfxinfo as Q,paths as X,phoneDir as Z,profileHash as $,profileLabels as ee,profileVars as ae,readBuoyInstallId as re,readLaunchFile as oe,readSummary as ie,removeLaunchFile as te,runAndroidCommand as ne,runFarmCommand as me,savePhone as de,simLaunchEnv as le,slimLabels as se,startFarmd as ue,summarize as Ee,writeLaunchFile as Le}from"./chunk-MJDNVDJI.mjs";import"./chunk-MTTWR4T7.mjs";export{a as ALWAYS_ENABLED,r as ANDROID_ADB_PORT,o as BUILT_IN_ANDROID_PHONES,i as BUILT_IN_PHONES,t as DEFAULT_ANDROID_PHONE,n as DEFAULT_PHONE,m as DEFAULT_SETTINGS,d as EmulatorHost,l as Engine,s as FarmClient,u as FarmError,E as LoadMeter,L as LocalDylibBuilder,p as PHONE_OFF,f as PROTOCOL_VERSION,_ as SLIM_CATEGORIES,A as STATE_VERSION,D as SimctlHost,O as Store,c as agentLabel,h as androidHelp,F as androidHome,I as bootEnv,T as buildDylib,N as classifyBootLog,S as connectFarm,P as copyCostGB,H as crashLoop,R as defaultCapMB,b as defaultSeedDir,B as detectAgent,C as eventsFilePath,y as farmAdbTarget,g as farmAdbTargets,v as farmDefaults,U as farmHelp,G as farmHome,q as farmOwnedUdids,x as findDataContainer,M as findExampleBuild,V as findRepoRoot,w as joinInsertList,j as launchFilePath,z as loadAndroidPhones,W as loadPhones,Y as measurePhone,k as nodeLauncher,J as parseCpuTime,K as parseEvents,Q as parseGfxinfo,X as paths,Z as phoneDir,$ as profileHash,ee as profileLabels,ae as profileVars,re as readBuoyInstallId,oe as readLaunchFile,ie as readSummary,te as removeLaunchFile,ne as runAndroidCommand,me as runFarmCommand,de as savePhone,le as simLaunchEnv,se as slimLabels,ue as startFarmd,Ee as summarize,Le as writeLaunchFile};
|
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.73",
|
|
5
5
|
"buoy": {
|
|
6
6
|
"dashboardAuthVersion": 1
|
|
7
7
|
},
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"socket.io-client": "^4.8.1",
|
|
37
37
|
"ws": "^8.18.0",
|
|
38
38
|
"zod": "^3.25.0",
|
|
39
|
-
"@buoy-gg/license": "^7.0.
|
|
40
|
-
"@buoy-gg/sync-broker": "^7.0.
|
|
39
|
+
"@buoy-gg/license": "^7.0.73",
|
|
40
|
+
"@buoy-gg/sync-broker": "^7.0.73"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^22.0.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"rimraf": "^5.0.0",
|
|
49
49
|
"tsup": "^8.5.1",
|
|
50
50
|
"typescript": "~5.8.3",
|
|
51
|
-
"@buoy-gg/sim-farm": "7.0.
|
|
51
|
+
"@buoy-gg/sim-farm": "7.0.73"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|
|
@@ -84,7 +84,7 @@ Open the dev server's network URL on a phone on the same Wi-Fi (for Vite, `vite
|
|
|
84
84
|
- A remount doesn't reset module state or storage. If you use `reloadBetweenCases`, make sure `AutomationResumer` is mounted.
|
|
85
85
|
- A simulator result holds for that simulator. Confirm on real hardware before claiming phone speed.
|
|
86
86
|
|
|
87
|
-
## Many ideas at once: the Sim Farm (iOS sims)
|
|
87
|
+
## Many ideas at once: the Sim Farm (iOS sims and Android copies)
|
|
88
88
|
|
|
89
89
|
Use the farm when the `sim_farm_*` tools exist and you have many ideas. Check `sim_farm_status` first. Its `roomFor` says how many more sims fit. A 16 GB Mac may fit only 1 to 3. Then pass `allowPartial: true`, and open the ideas one after another on the sims you got.
|
|
90
90
|
|
|
@@ -101,6 +101,15 @@ Farm apps run as a real phone: an iPhone SE (2nd gen) by default. The app sees t
|
|
|
101
101
|
|
|
102
102
|
Farm numbers come from a slim debug sim. Say so in your report, and confirm the finalists on a real device.
|
|
103
103
|
|
|
104
|
+
### Android copies
|
|
105
|
+
|
|
106
|
+
The same tools run Android. Pass `platform: "android"` to `sim_farm_acquire`. Each copy starts from a saved phone in about 2 s, with the app on it.
|
|
107
|
+
|
|
108
|
+
- **Template first.** If `sim_farm_status` has no usable template under `android`, make one with `sim_farm_setup`. Use `mode: "create"`, `platform: "android"` and the app's debug `apkPath`. For a dev build on Metro, pass `reverse: ["8081:<metro port>"]`. It takes about a minute. If the farm says the Android SDK is missing, ask the person to run `npx @buoy-gg/mcp farm android setup`. They accept Google's license there.
|
|
109
|
+
- **Room.** A copy costs more than an iOS sim: about 4.3 GB of the Mac's memory for the default 3 GB phone. Each Android template in `sim_farm_status` shows `costGB` and `roomFor`.
|
|
110
|
+
- **Steps.** Look and speed work the same as on iOS, `perf-slot` too. `deviceId` and the Buoy tools work on copies. `reset` and `rebuild` swap in a fresh copy with a new id, and the result maps old to new. To try a native change, use `sim_farm_action` `install` with an `apkPath`.
|
|
111
|
+
- **Phone.** A copy is a real Android phone with its own RAM, cores, screen and app memory limit (a 3 GB low-end phone by default). If Android closes the app for memory, the copy shows `overLimit: true` and the run gets a note. Mark that idea `invalid` and say why.
|
|
112
|
+
|
|
104
113
|
## Tool list
|
|
105
114
|
|
|
106
115
|
| Tool | Use |
|
|
@@ -114,7 +123,7 @@ Farm numbers come from a slim debug sim. Say so in your report, and confirm the
|
|
|
114
123
|
| `run_benchmark_batch` / `get_automation_status` | Measure variants in the app with Bench. |
|
|
115
124
|
| `get_batch_report` / `compare_reports` | Read or compare saved runs. |
|
|
116
125
|
| `measure_renders` | Count renders in one interaction. |
|
|
117
|
-
| `sim_farm_acquire` / `sim_farm_release` / `sim_farm_finish` | Take farm sims for a run, give them back and end the run. |
|
|
126
|
+
| `sim_farm_acquire` / `sim_farm_release` / `sim_farm_finish` | Take farm sims or Android copies for a run, give them back and end the run. |
|
|
118
127
|
| `sim_farm_action` | Open a link, take a still or relaunch the app on your farm sims. |
|
|
119
128
|
| `sim_farm_lock` | Hold `perf-slot` for fair speed tests on one farm sim. |
|
|
120
129
|
| `sim_farm_report` | Tell the person how each idea is going. |
|
|
@@ -90,7 +90,7 @@ Then measure every valid variant the same way. Screen them at a few loads (one l
|
|
|
90
90
|
The ceiling is the score.
|
|
91
91
|
|
|
92
92
|
- On web, use `find_web_ceiling`. It raises the load about 30% a step until the frame rate misses the target, then narrows the gap to a few percent. It has no cap by default (up to 1,000,000,000).
|
|
93
|
-
- On React Native, ramp the count with `run_benchmark_batch` cases. With many ideas on iOS sims, use the Sim Farm (see MEASURING.md): many sims check looks, and one quiet sim measures speed.
|
|
93
|
+
- On React Native, ramp the count with `run_benchmark_batch` cases. With many ideas on iOS sims or Android copies, use the Sim Farm (see MEASURING.md): many sims check looks, and one quiet sim measures speed.
|
|
94
94
|
- Record the highest pass and the lowest miss. A single miss can be noise; run the boundary again before you call it.
|
|
95
95
|
- If nothing missed, the score is "at least" that load. Raise the max and run again.
|
|
96
96
|
- **Rank on the score.** Dropped frames, frame times, CPU, memory and profiles explain why a variant won. They only rank when the contract says so.
|
package/dist/chunk-EVLLWLFB.mjs
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import{createRequire as F}from"module";const J=F(import.meta.url);import{FarmError as k,agentLabel as q,connectFarm as B,detectAgent as U,findDataContainer as E,nodeLauncher as $,readBuoyInstallId as O}from"./chunk-N573FXZ7.mjs";import{readEntitlement as L}from"./chunk-OUCB2Y5J.mjs";import j from"os";import C from"path";import{z as o}from"zod";function x(t){if(L().admitted)return true;if(!t)return false;if(t.isAdmitted?.())return true;return t.connectedDevices().some(r=>r.isPro===true||r.tier==="free")}function P(t){return`\`${t}\` needs a Buoy account. A free one works.
|
|
2
|
-
|
|
3
|
-
Tell the user to sign in to Buoy Desktop, or run \`npx buoy login\` in the project. Then run this again.`}var G=t=>({content:[{type:"text",text:t}]});var w=t=>({isError:true,content:[{type:"text",text:t}]});var c=t=>G(JSON.stringify(t,null,2));var R;var I=t=>x(R)?null:w(P(t));var v=null;var M=()=>void 0;function _(){if(!v){v=U({clientName:M()}).catch(()=>void 0).then(t=>B({label:process.env.BUOY_AGENT_LABEL||(t?q(t):"Buoy MCP"),agent:t,launcher:$(process.argv[1],["farmd"])})).catch(t=>{v=null;throw t})}return v}async function u(t,r,e,n){return(await _()).call(t,r,{timeoutMs:e,requestId:n})}var h=[];function A(t){for(let r=h.length-1;r>=0;r--)if(t(h[r]))h.splice(r,1)}async function N(t,r){const e=()=>h.find(s=>s.buoyDeviceId&&t.endsWith(s.buoyDeviceId));let n=e();if(!n&&h.length){await z().catch(()=>void 0);n=e()}if(!n)return null;try{return{checked:true,...await u("fairness",{leaseId:n.leaseId,epoch:n.epoch,windows:r},3e4)}}catch(s){if(s instanceof k&&s.code==="BAD_REQUEST"&&/Unknown method/.test(s.message)){return{checked:false,why:"This farm is too old to check fairness. Restart Buoy Desktop when the farm is idle."}}return{checked:false,why:s instanceof k?`${s.code}: ${s.message}`:String(s?.message??s)}}}async function z(){const t=await u("status");const r=new Set(h.map(e=>e.leaseId));for(const e of t.sims){if(!e.leaseId||!r.has(e.leaseId))continue;let n=h.find(p=>p.udid===e.udid);if(!n){const p=h.find(b=>b.leaseId===e.leaseId);n={...p,udid:e.udid,buoyDeviceId:void 0};h.push(n)}if(e.buoyDeviceId){n.buoyDeviceId=e.buoyDeviceId;continue}const s=t.templates.find(p=>p.id===e.templateId)?.bundleId;const i=C.join(j.homedir(),"Library/Developer/CoreSimulator/Devices",e.udid,"data");const a=s?await E(i,s):null;const l=a?await O(a):null;if(l)n.buoyDeviceId=l}}function y(t){if(t instanceof k&&t.code==="RUN_STOPPED"){return w('RUN_STOPPED: The person stopped this run in Buoy Desktop (reason=user). Stop work on it. Tell them: "You stopped this run in Sims." Start a new run only if they ask.')}if(t instanceof k)return w(`${t.code}: ${t.message}${t.next?`
|
|
4
|
-
Next: ${t.next}`:""}`);return w(`Sim Farm error: ${t?.message??t}`)}var D={leaseId:o.string().describe("The lease id from sim_farm_acquire."),epoch:o.number().int().describe("The epoch from sim_farm_acquire. A stale epoch is refused.")};var S=o.string().optional().describe("Any unique string for this call. Repeating the same call with the same requestId returns the first result instead of acting twice.");var T=o.string().describe("The runId from sim_farm_acquire.");async function W(t,r,e){const n=new Map;const s=Date.now()+e;for(;;){for(const i of r){if(n.has(i))continue;const a=t.connectedDevices().find(l=>l.deviceId.endsWith(i)&&l.tier!=="checking");if(a)n.set(i,a.deviceId)}if(n.size===r.length||Date.now()>=s)return n;await new Promise(i=>setTimeout(i,500))}}function H(t,r){M=()=>t.server.getClientVersion()?.name;R=r;t.registerTool("sim_farm_status",{description:"Show the Sim Farm: how many farm simulators exist, which are awake or in use and by whom, free memory and disk, how many more sims fit, templates, leases, and recent events. Farm sims are slim iOS simulators (about 1.5 GB each instead of 5) that the farm makes from a template. Apps launch as a real phone (an iPhone SE, 2nd gen, by default): they see its RAM and cores and get its memory warnings. Each sim's `phone` shows the app's peak memory, and `overLimit: true` means a real phone would have closed the app. A safety cap closes an app that keeps growing. Start here before sim_farm_acquire. Set `outside` to also list this Mac's other simulators, which the farm never touches.",inputSchema:{outside:o.boolean().optional().describe("Also list simulators the farm does not own.")}},async({outside:e})=>{try{return c(await u("status",{outside:!!e}))}catch(n){return y(n)}});t.registerTool("sim_farm_setup",{description:"Create or resize the Sim Farm. `create` makes a template sim from an app build and, optionally, a seed folder (signed-in app data); clones are made from it in about 4 s each. Dev-client apps (Expo) need `launchUrl` so they open on Metro instead of the launcher. `grow` / `shrink` change how many sims exist. Use `dryRun` first: it returns the plan, what slim mode turns off (push, StoreKit, app links, widgets, Spotlight\u2026) and whether the Mac has room. You own the pool: Buoy Desktop has no size control. sim_farm_acquire makes sims as needed; `shrink` the ones you no longer need so they stop using disk space.",inputSchema:{mode:o.enum(["create","grow","shrink"]).describe("create = new template; grow/shrink = pool size."),templateId:o.string().optional().describe("For grow/shrink. Defaults to the newest usable template."),count:o.number().int().min(1).max(64).optional().describe("For grow/shrink: how many sims to add or remove."),appPath:o.string().optional().describe("For create: path to a simulator .app build."),seedDir:o.string().nullable().optional().describe("For create: seed folder (Documents/, AsyncStorage/, Preferences/, seed.json). null = no seed."),launchUrl:o.string().optional().describe("For create: URL opened after each launch, e.g. exp+my-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081."),deviceType:o.string().optional().describe("For create: a simctl device type id. Default iPhone 17 Pro."),runtime:o.string().optional().describe("For create: a simctl runtime id. Default iOS 26.3."),profile:o.enum(["slim","stock"]).optional().describe("For create: slim (default) or stock."),metroPort:o.number().int().min(1).max(65535).optional().describe("For create: the Metro port the app loads from. Default 8802."),dryRun:o.boolean().optional().describe("Return the plan and its cost without changing anything."),requestId:S}},async e=>{const n=I("sim_farm_setup");if(n)return n;try{if(e.mode==="create"){const a={appPath:e.appPath,seedDir:e.seedDir,deviceType:e.deviceType??"com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro",runtime:e.runtime??"com.apple.CoreSimulator.SimRuntime.iOS-26-3",profile:e.profile??"slim",metroPort:e.metroPort??8802,...e.launchUrl?{launchUrl:e.launchUrl}:{}};if(!a.appPath)return w("create needs appPath.");if(a.seedDir===void 0)return w("create needs seedDir, or seedDir: null for an app with no seed.");if(e.dryRun)return c({wouldCreateTemplate:a,budget:await u("budget",{ramGB:6,diskGB:4})});return c(await u("setup",a,15*6e4,e.requestId))}let s=e.templateId;if(!s){const a=await u("status");s=a.templates.filter(l=>l.usable).pop()?.id;if(!s)return w("No usable template yet. Run sim_farm_setup with mode create first.")}const i=e.count??1;if(e.dryRun){const a=await u("budget",{ramGB:0,diskGB:e.mode==="grow"?2*i:0});return c({mode:e.mode,templateId:s,count:i,roomForMoreSims:a.roomFor,ok:a.ok,reason:a.reason})}return c(await u(e.mode,{templateId:s,count:i},30*6e4,e.requestId))}catch(s){return y(s)}});t.registerTool("sim_farm_acquire",{description:"Take farm simulators for your work. The person watches your work live in Buoy Desktop, so give a clear `goal` and a `labels` entry per sim. Returns a run (runId, deadlineAt), a lease (leaseId + epoch) and exact sims (UDID, name, and buoyDeviceId once the app ran: the 8-character tail that its list_devices id ends with; with launch, also deviceId, the full id to pass to other tools). Parked sims are booted and new ones cloned as needed, within the Mac's memory and disk limits. This MCP keeps the lease alive while it runs; release it with sim_farm_finish when done. Only ever target the returned UDIDs: never `booted`, which picks a random simulator when many run. While working, send sim_farm_report when a sim starts, passes or fails. The run stops at deadlineAt unless you ask for more time with sim_farm_extend. If any call returns RUN_STOPPED, the person stopped you: stop and tell them.",inputSchema:{count:o.number().int().min(1).max(64).describe("How many sims. There is no fixed cap: the farm gives as many as this Mac's memory and disk allow. Use allowPartial to take what fits."),goal:o.string().max(200).optional().describe("One short sentence the person sees on the run card, e.g. 'Make the menu list scroll faster'. With runId, the run's goal is used if you leave this out."),labels:o.array(o.string().max(80)).optional().describe("One label per sim, in order, e.g. ['Idea 1: memo rows', 'Idea 2: flat list']."),kind:o.enum(["try-ideas","run-tests","reproduce-bug","explore"]).optional().describe("What sort of work this is."),runId:o.string().optional().describe("Add these sims to a run you already have, instead of starting a new one."),purpose:o.string().optional().describe("Old name for goal. Use goal."),templateId:o.string().optional().describe("Defaults to the newest usable template."),launch:o.boolean().optional().describe("Launch the app on each sim and wait (up to about 30 s) until it joins Buoy. Each sim then has deviceId for other tools."),allowPartial:o.boolean().optional().describe("Return fewer sims than asked instead of failing."),waitSeconds:o.number().int().min(0).max(1800).optional().describe("Wait this long for sims to free up."),ttlMinutes:o.number().int().min(1).max(1440).optional().describe("How long the run may go. Default 15. Agents are capped (1 hour by default); ask for more later with sim_farm_extend."),requestId:S}},async e=>{const n=I("sim_farm_acquire");if(n)return n;try{let s=e.templateId;if(!s){const d=await u("status");s=d.templates.filter(f=>f.usable).pop()?.id;if(!s)return w("No usable template yet. Run sim_farm_setup with mode create first.")}let i=(e.goal??e.purpose??"").trim();if(!i&&e.runId)i=((await u("status")).runs??[]).find(d=>d.id===e.runId)?.goal??"";if(!i)return w("Give a goal: one short sentence about what you are doing.");const a=await _();const l=await a.acquire({templateId:s,count:e.count,purpose:i,goal:i,labels:e.labels,kind:e.kind,runId:e.runId,launch:e.launch,allowPartial:e.allowPartial,waitMs:(e.waitSeconds??0)*1e3,ttlMs:(e.ttlMinutes??15)*6e4,requestId:e.requestId});for(const d of l.sims){const f=h.findIndex(g=>g.udid===d.udid);if(f>=0)h.splice(f,1);h.push({leaseId:l.leaseId,epoch:l.epoch,runId:l.runId,udid:d.udid,buoyDeviceId:d.buoyDeviceId??void 0})}let p=l;if(e.launch&&r){const d=l.sims.map(m=>m.buoyDeviceId).filter(m=>!!m);const f=d.length?await W(r,d,3e4):new Map;p={...l,sims:l.sims.map(m=>({...m,deviceId:m.buoyDeviceId&&f.get(m.buoyDeviceId)||null}))};const g=l.sims.filter(m=>m.buoyDeviceId&&!f.has(m.buoyDeviceId));if(g.length){p.warning=`The app started on ${g.map(m=>m.name).join(", ")} but has not joined Buoy after 30 s. Run list_devices again in a moment, or take a screenshot with sim_farm_action to see the screen.`}}const b=e.launch?l.sims.filter(d=>!d.buoyDeviceId):[];if(b.length){const d=(await u("status").catch(()=>null))?.templates?.find(g=>g.id===s);const f=d?.metroPort?` on port ${d.metroPort}`:"";return c({...p,warning:`The app did not report a Buoy id on ${b.map(g=>g.name).join(", ")}. It may still be loading, or Metro${f} may be down or broken. Check Metro${f}, then take a screenshot with sim_farm_action to see the screen.`})}return c(p)}catch(s){return y(s)}});t.registerTool("sim_farm_release",{description:"Give one lease's sims back. `fast` (default) stops the app and restores its seeded data in about 2 s; `full` rebuilds each sim from the template. Use `full` after anything changed OS settings (permissions, keychain, location) or after a crash. Releasing your last lease ends the run, unless `keepRun` is true. To end a run with a result, use sim_farm_finish instead.",inputSchema:{...D,reset:o.enum(["fast","full"]).optional(),keepRun:o.boolean().optional().describe("Keep the run open to take more sims into it later.")}},async e=>{try{const n=await _();const s=await n.release(e.leaseId,e.epoch,e.reset??"fast",e.keepRun===true);A(i=>i.leaseId===e.leaseId);return c(s)}catch(n){return y(n)}});t.registerTool("sim_farm_action",{description:"Act on sims in your lease: `screenshot` (returns file paths), `open-url` (deep link, pass `url`), `launch` the app, `reset` (restore seeded app data), or `rebuild` (fresh clone; the UDID changes, the result maps old to new).",inputSchema:{...D,udids:o.array(o.string()).min(1).describe("UDIDs from your lease."),action:o.enum(["screenshot","open-url","launch","reset","rebuild"]),url:o.string().optional().describe("For open-url."),requestId:S}},async e=>{const n=I("sim_farm_action");if(n)return n;try{return c(await u("action",{leaseId:e.leaseId,epoch:e.epoch,udids:e.udids,action:e.action,arg:e.url},10*6e4,e.requestId))}catch(s){return y(s)}});t.registerTool("sim_farm_lock",{description:"Hold or free a Mac-wide lock tied to your lease. `perf-slot` is for fair speed tests: while you hold it the farm boots, clones and screenshots nothing else, and it records how busy the Mac is every second. It waits only for farm work in flight, not for other runs to end, so other sims and other apps may still use the Mac. run_benchmark_batch on your farm sim then marks each run fair or unfair from that record; run the unfair cases again. Measure only while you hold it. Other names guard shared test fixtures (e.g. `ask-buoy-gateway`, `shop`). Locks end with the lease.",inputSchema:{...D,name:o.string().regex(/^[a-z0-9-]{1,40}$/).describe("Lock name, e.g. perf-slot."),hold:o.boolean().describe("true = take it, false = free it."),waitSeconds:o.number().int().min(0).max(1800).optional()}},async e=>{const n=I("sim_farm_lock");if(n)return n;try{const s={leaseId:e.leaseId,epoch:e.epoch,name:e.name,waitMs:(e.waitSeconds??0)*1e3};return c(await u(e.hold?"lock":"unlock",s,(e.waitSeconds??0)*1e3+3e4))}catch(s){return y(s)}});t.registerTool("sim_farm_report",{description:"Tell the person how your run is going. They watch it live in Buoy Desktop. Send one when a sim starts work, passes, fails or needs help, with a short note. `counts` only when you know the full task list. Reports never add time. Pass an increasing `sequence` so a retry is never counted twice.",inputSchema:{runId:T,udid:o.string().optional().describe("The sim this is about. Leave out for the whole run."),state:o.enum(["setting-up","working","waiting","passed","failed","needs-help"]).optional(),note:o.string().max(200).optional().describe("Short and plain, e.g. 'Idea 3: 58 fps, best so far'."),counts:o.object({done:o.number().int().min(0),total:o.number().int().min(0),passed:o.number().int().min(0).optional(),failed:o.number().int().min(0).optional()}).optional(),resultSource:o.enum(["agent","test"]).optional().describe("'test' when a test runner decided pass or fail, 'agent' when you judged it. Default agent."),sequence:o.number().int().min(0).optional()}},async e=>{try{return c(await u("report",e,3e4))}catch(n){return y(n)}});t.registerTool("sim_farm_extend",{description:"Ask for more time on your run before deadlineAt. The farm never goes past the run's cap (capAt). It says how much you got.",inputSchema:{runId:T,minutes:o.number().int().min(1).max(1440).describe("Minutes from now.")}},async e=>{try{return c(await u("extend",e))}catch(n){return y(n)}});t.registerTool("sim_farm_finish",{description:"End your run with a result. Gives back every sim it still holds. The person sees the summary in Buoy Desktop, so make it one or two plain sentences, e.g. 'Idea 3 won: 52 \u2192 58 fps.'",inputSchema:{runId:T,outcome:o.enum(["passed","failed","mixed","unknown"]).optional(),summary:o.string().max(600).optional()}},async e=>{try{const n=await u("finish",e,6e4,`fin-${e.runId}`);A(s=>s.runId===e.runId);return c(n)}catch(n){return y(n)}})}async function Y(){const{startFarmd:t}=await import("./src-TOKDOFTX.mjs");const r=await t({hostLabel:"buoy-mcp",idleExitMs:10*6e4,onExit:()=>process.exit(0)});if(!r)process.exit(0);for(const e of["SIGTERM","SIGINT"])process.on(e,()=>void r.stop(e))}export{x as hasBuoyAccount,P as accountRequired,N as farmFairness,H as registerSimFarmTools,Y as runFarmd};
|