@buoy-gg/mcp 7.0.2 → 7.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -325,7 +325,7 @@ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
325
325
 
326
326
  // src/server.ts
327
327
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
328
- var import_zod13 = require("zod");
328
+ var import_zod15 = require("zod");
329
329
 
330
330
  // src/tools/generic.ts
331
331
  var import_zod = require("zod");
@@ -336,7 +336,7 @@ var import_node_os = __toESM(require("os"));
336
336
  var import_node_path = __toESM(require("path"));
337
337
 
338
338
  // src/version.ts
339
- var VERSION = "7.0.2" ? "7.0.2" : "0.0.0-dev";
339
+ var VERSION = "7.0.4" ? "7.0.4" : "0.0.0-dev";
340
340
  var PACKAGE_NAME = "@buoy-gg/mcp";
341
341
 
342
342
  // src/update/checkForUpdate.ts
@@ -2144,6 +2144,439 @@ ${elements.map(formatElement).join("\n")}`);
2144
2144
  );
2145
2145
  }
2146
2146
 
2147
+ // src/tools/packs/jsThread.ts
2148
+ var import_zod13 = require("zod");
2149
+ var JS_TOP_TOOL_ID = "js-top";
2150
+ var text11 = (s) => ({ content: [{ type: "text", text: s }] });
2151
+ var fail13 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
2152
+ var errMsg13 = (e) => e instanceof Error ? e.message : String(e);
2153
+ var SPARK_CHARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
2154
+ function sparkline(values) {
2155
+ const max = Math.max(1, ...values);
2156
+ return values.map((v) => SPARK_CHARS[Math.min(7, Math.floor(v / max * 7.999))]).join("");
2157
+ }
2158
+ function formatTopMarkdown(snap, durationMs) {
2159
+ const lines = [];
2160
+ lines.push(`## JS thread top (${(durationMs / 1e3).toFixed(1)}s sample)`);
2161
+ lines.push("");
2162
+ lines.push(`Thread busy: **${snap.busyPct.toFixed(1)}%** of the trailing 5s window.`);
2163
+ if (snap.freezeSummary.count > 0) {
2164
+ lines.push(
2165
+ `Freezes (\u226550ms single-task blocks, last 60s): **${snap.freezeSummary.count}**, worst **${snap.freezeSummary.worstMs}ms** \u2014 during these, taps and JS animations stall.`
2166
+ );
2167
+ }
2168
+ if (!snap.tiers.bridge) {
2169
+ lines.push(
2170
+ "_New Architecture device: touch/React commit work appears as \u201Cunattributed\u201D; timers, rAF and Promise chains are fully attributed._"
2171
+ );
2172
+ }
2173
+ lines.push("");
2174
+ lines.push("| # | task origin | api | 5s ms | % busy | calls | avg ms | max ms | freezes |");
2175
+ lines.push("|---|---|---|---|---|---|---|---|---|");
2176
+ snap.rows.slice(0, 15).forEach((r, i) => {
2177
+ const freezeCell = r.freezes > 0 ? `\u26A0 ${r.freezes} (worst ${r.worstFreezeMs}ms)` : "\u2013";
2178
+ lines.push(
2179
+ `| ${i + 1} | ${r.label} | ${r.api} | ${r.windowMs.toFixed(1)} | ${r.pctOfBusy.toFixed(1)} | ${r.calls} | ${r.avgMs.toFixed(2)} | ${r.maxMs.toFixed(1)} | ${freezeCell} |`
2180
+ );
2181
+ });
2182
+ lines.push("");
2183
+ lines.push(
2184
+ "_Drill into any row with `get_js_thread_origin_detail` using its `key` from the JSON below._"
2185
+ );
2186
+ return lines.join("\n");
2187
+ }
2188
+ function formatDetailMarkdown(d) {
2189
+ const lines = [];
2190
+ lines.push(`## Origin detail \u2014 ${d.label}`);
2191
+ lines.push("");
2192
+ lines.push(`API: \`${d.api}\` \xB7 key: \`${d.key}\``);
2193
+ lines.push("");
2194
+ lines.push(`Activity last 5s (250ms buckets): \`${sparkline(d.buckets)}\` \u2014 ${d.windowMs.toFixed(1)}ms in window, ${d.pctOfBusy.toFixed(1)}% of busy time.`);
2195
+ lines.push("");
2196
+ lines.push("| runs | scheduled | avg/run | max/run | total | last run | freezes (60s) | worst freeze |");
2197
+ lines.push("|---|---|---|---|---|---|---|---|");
2198
+ lines.push(
2199
+ `| ${d.calls} | ${d.scheduleCount} | ${d.avgMs.toFixed(2)}ms | ${d.maxMs.toFixed(1)}ms | ${d.totalMs.toFixed(1)}ms | ${d.lastSeenAgoMs > 0 ? `${Math.round(d.lastSeenAgoMs / 1e3)}s ago` : "\u2014"} | ${d.freezes} | ${d.worstFreezeMs > 0 ? `${d.worstFreezeMs}ms` : "\u2014"} |`
2200
+ );
2201
+ if (d.caller || d.file) {
2202
+ lines.push("");
2203
+ lines.push(`Scheduled from: **${d.caller ? `${d.caller}()` : "unknown caller"}**${d.file ? ` \u2014 ${d.file}${d.line != null ? `:${d.line}` : ""}` : ""}`);
2204
+ if (d.frames?.length) {
2205
+ lines.push(
2206
+ `Raw frames (Hermes bundle offsets): ${d.frames.map((f) => `${f.file}:${f.line}:${f.col}`).join(" \xB7 ")}`
2207
+ );
2208
+ }
2209
+ } else if (d.api !== "unattributed" && d.api !== "other") {
2210
+ lines.push("");
2211
+ lines.push(
2212
+ d.promoted ? "_Scheduling stack not captured yet \u2014 the next schedule from this site records it._" : "_Not hot enough for stack capture yet (threshold: 25 schedules or 50ms total)._"
2213
+ );
2214
+ }
2215
+ return lines.join("\n");
2216
+ }
2217
+ function registerJsThreadTools(server, sync) {
2218
+ server.registerTool(
2219
+ "get_js_thread_top",
2220
+ {
2221
+ description: "Task Manager for the React Native JS thread: sample for a few seconds and return a ranked table of which task origins (timers, Promise chains, rAF callbacks, bridge call-ins) consumed JS-thread time \u2014 plus thread busy% and freeze (50ms+ block) attribution. Use when JS FPS is low and you need to know WHAT is eating the thread. To profile one specific interaction, pass clearFirst:true, then drive the UI with tap_element while the sample runs. Drill into a row with get_js_thread_origin_detail. Requires @buoy-gg/js-top on the device.",
2222
+ inputSchema: {
2223
+ deviceId: import_zod13.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2224
+ durationMs: import_zod13.z.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
2225
+ clearFirst: import_zod13.z.boolean().optional().describe("Reset all stats before sampling, so the result attributes only what happens during this sample.")
2226
+ }
2227
+ },
2228
+ async ({ deviceId, durationMs, clearFirst }) => {
2229
+ try {
2230
+ const target = sync.requireProDevice(deviceId);
2231
+ const duration = Math.min(Math.max(durationMs ?? 3e3, 250), 3e4);
2232
+ const snap = await sync.callAction(
2233
+ JS_TOP_TOOL_ID,
2234
+ "sample",
2235
+ { durationMs: duration, clearFirst: clearFirst === true },
2236
+ target
2237
+ );
2238
+ if (!snap) return fail13("Device returned no snapshot \u2014 is @buoy-gg/js-top installed in the app?");
2239
+ const md = formatTopMarkdown(snap, duration);
2240
+ const slim = {
2241
+ busyPct: Math.round(snap.busyPct * 10) / 10,
2242
+ freezeSummary: snap.freezeSummary,
2243
+ tiers: snap.tiers,
2244
+ rows: snap.rows.slice(0, 15).map((r) => ({
2245
+ key: r.key,
2246
+ api: r.api,
2247
+ windowMs: Math.round(r.windowMs * 10) / 10,
2248
+ pctOfBusy: Math.round(r.pctOfBusy * 10) / 10,
2249
+ calls: r.calls,
2250
+ avgMs: Math.round(r.avgMs * 100) / 100,
2251
+ maxMs: Math.round(r.maxMs * 10) / 10,
2252
+ totalMs: Math.round(r.totalMs),
2253
+ freezes: r.freezes,
2254
+ worstFreezeMs: r.worstFreezeMs
2255
+ }))
2256
+ };
2257
+ return text11(`${md}
2258
+
2259
+ \`\`\`json
2260
+ ${JSON.stringify(slim)}
2261
+ \`\`\``);
2262
+ } catch (e) {
2263
+ return fail13(errMsg13(e));
2264
+ }
2265
+ }
2266
+ );
2267
+ server.registerTool(
2268
+ "get_js_thread_origin_detail",
2269
+ {
2270
+ description: 'Inspect one js-top task origin in depth: schedules vs runs, avg/max/total exclusive ms, a 5s activity sparkline, freeze attribution, and the captured scheduling site (caller function + stack frames). Pass a row `key` from get_js_thread_top\'s JSON (e.g. "interval|pollFeed" or "unattributed" for the system row).',
2271
+ inputSchema: {
2272
+ deviceId: import_zod13.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2273
+ key: import_zod13.z.string().describe("Origin key from get_js_thread_top's JSON rows.")
2274
+ }
2275
+ },
2276
+ async ({ deviceId, key }) => {
2277
+ try {
2278
+ const target = sync.requireProDevice(deviceId);
2279
+ const detail = await sync.callAction(
2280
+ JS_TOP_TOOL_ID,
2281
+ "getOriginDetail",
2282
+ { key },
2283
+ target
2284
+ );
2285
+ if (!detail) {
2286
+ return fail13(
2287
+ `Origin "${key}" is not tracked (cleared, evicted, or never existed). Re-run get_js_thread_top for current keys.`
2288
+ );
2289
+ }
2290
+ const md = formatDetailMarkdown(detail);
2291
+ return text11(`${md}
2292
+
2293
+ \`\`\`json
2294
+ ${JSON.stringify(detail)}
2295
+ \`\`\``);
2296
+ } catch (e) {
2297
+ return fail13(errMsg13(e));
2298
+ }
2299
+ }
2300
+ );
2301
+ server.registerTool(
2302
+ "clear_js_thread_stats",
2303
+ {
2304
+ description: "Reset the js-top task table, freeze history, and busy history on the device (fresh measurement window).",
2305
+ inputSchema: {
2306
+ deviceId: import_zod13.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2307
+ }
2308
+ },
2309
+ async ({ deviceId }) => {
2310
+ try {
2311
+ const target = sync.requireProDevice(deviceId);
2312
+ await sync.callAction(JS_TOP_TOOL_ID, "clear", {}, target);
2313
+ return text11("Cleared js-top stats.");
2314
+ } catch (e) {
2315
+ return fail13(errMsg13(e));
2316
+ }
2317
+ }
2318
+ );
2319
+ }
2320
+
2321
+ // src/tools/packs/images.ts
2322
+ var import_zod14 = require("zod");
2323
+ var IMAGES_TOOL_ID = "images";
2324
+ var text12 = (s) => ({ content: [{ type: "text", text: s }] });
2325
+ var fail14 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
2326
+ var errMsg14 = (e) => e instanceof Error ? e.message : String(e);
2327
+ function uriTail(uri) {
2328
+ if (!uri) return "(no source)";
2329
+ const unstable = uri.match(/[?&]unstable_path=([^&?]+)/);
2330
+ const effective = unstable ? decodeURIComponent(unstable[1]) : uri;
2331
+ const noQuery = effective.split("?")[0] ?? effective;
2332
+ const segs = noQuery.split("/").filter(Boolean);
2333
+ return segs[segs.length - 1] ?? effective;
2334
+ }
2335
+ function verdictLabel(r) {
2336
+ if (r.oversizeFactor == null) return "\u2013";
2337
+ if (r.oversizeFactor < 0.9) return `${r.oversizeFactor.toFixed(1)}\xD7 upscaled`;
2338
+ if (r.oversizeFactor <= 1.1) return "right-sized";
2339
+ return `${r.oversizeFactor.toFixed(1)}\xD7 oversized (${r.wastedKB}KB wasted)`;
2340
+ }
2341
+ function cacheLabel(r) {
2342
+ if (r.cache) return r.cache;
2343
+ if (r.progressSeen) return "network";
2344
+ return "?";
2345
+ }
2346
+ function mb(bytes) {
2347
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
2348
+ }
2349
+ function formatListMarkdown(list, limit) {
2350
+ const lines = [];
2351
+ const s = list.stats;
2352
+ lines.push(`## Images registry (${list.total} records, showing ${Math.min(limit, list.records.length)})`);
2353
+ lines.push("");
2354
+ lines.push(
2355
+ `${s.loaded} loaded \xB7 ${s.errors} failed \xB7 ${s.loading} in flight \xB7 \u2248${mb(s.estDecodedBytes)} decoded` + (s.estWastedBytes > 0 ? ` \xB7 **${mb(s.estWastedBytes)} wasted on oversized sources**` : "")
2356
+ );
2357
+ const ins = list.insights;
2358
+ if (ins) {
2359
+ const chips = [];
2360
+ if (ins.duplicates.length > 0)
2361
+ chips.push(`${ins.duplicates.length} duplicate URL(s): ${ins.duplicates.slice(0, 3).map((d) => `${uriTail(d.uri)}\xD7${d.count}`).join(", ")}`);
2362
+ if (ins.retryStorms.length > 0)
2363
+ chips.push(`${ins.retryStorms.length} retry storm(s): ${ins.retryStorms.slice(0, 3).map((r) => `#${r.id}(${r.loadCount} loads)`).join(", ")}`);
2364
+ if (ins.queueSaturated) chips.push("iOS image queue saturated (>4 concurrent loads)");
2365
+ if (ins.missingAlt > 0) chips.push(`${ins.missingAlt} image(s) missing alt/accessibilityLabel`);
2366
+ if (ins.layoutShifters.length > 0)
2367
+ chips.push(`${ins.layoutShifters.length} layout-shifting image(s): ${ins.layoutShifters.slice(0, 3).map((l) => `#${l.id}`).join(", ")} \u2014 reserve explicit dimensions`);
2368
+ if (chips.length > 0) {
2369
+ lines.push("");
2370
+ lines.push(`**Insights:** ${chips.join(" \xB7 ")}`);
2371
+ }
2372
+ }
2373
+ const cs = list.captureStatus;
2374
+ if (cs.rnDecoratorTooLate) {
2375
+ lines.push("");
2376
+ lines.push('\u26A0 RN <Image> capture missed the module-load race \u2014 add `import "@buoy-gg/images/register"` as the FIRST import of the app entry file.');
2377
+ }
2378
+ if (list.globalModes.network !== "normal" || list.globalModes.blank) {
2379
+ lines.push("");
2380
+ lines.push(`\u26A0 Simulation active: network=${list.globalModes.network}${list.globalModes.blank ? ", blank images ON" : ""} (set_image_simulation to reset).`);
2381
+ }
2382
+ lines.push("");
2383
+ lines.push("| id | image | lib | status | cache | ms | decoded px | shown dp | verdict | sim |");
2384
+ lines.push("|---|---|---|---|---|---|---|---|---|---|");
2385
+ for (const r of list.records) {
2386
+ const dims = r.intrinsic ? `${r.intrinsic.width}\xD7${r.intrinsic.height}` : "\u2013";
2387
+ const layout = r.layout ? `${r.layout.width}\xD7${r.layout.height}` : "\u2013";
2388
+ const status = r.status === "error" ? `**error**${r.errorCode ? ` (${r.errorCode})` : ""}` : r.status;
2389
+ lines.push(
2390
+ `| ${r.id} | ${uriTail(r.uri)} | ${r.lib} | ${status}${r.mounted ? "" : " (unmounted)"} | ${cacheLabel(r)} | ${r.ms ?? "\u2013"} | ${dims} | ${layout} | ${verdictLabel(r)} | ${r.overrideLabel ?? "\u2013"} |`
2391
+ );
2392
+ }
2393
+ lines.push("");
2394
+ lines.push("_Act on a row with `image_action` using its `id` (hard_reload / retry / force_error / force_loading / force_blank / swap_url / flash / restore / evict_disk)._");
2395
+ return lines.join("\n");
2396
+ }
2397
+ var ACTION_MAP = {
2398
+ hard_reload: { action: "hardReload" },
2399
+ retry: { action: "retry" },
2400
+ evict_disk: { action: "evictDisk" },
2401
+ flash: { action: "flash" },
2402
+ restore: { action: "clearOverride" },
2403
+ force_error: { action: "setOverride", params: () => ({ kind: "error" }) },
2404
+ force_loading: { action: "setOverride", params: () => ({ kind: "hang" }) },
2405
+ force_blank: { action: "setOverride", params: () => ({ kind: "blank" }) },
2406
+ swap_url: { action: "setOverride", params: (url) => ({ kind: "url", uri: url }) }
2407
+ };
2408
+ function registerImagesTools(server, sync) {
2409
+ server.registerTool(
2410
+ "get_images",
2411
+ {
2412
+ description: "Live image registry from the app: every RN <Image> and expo-image load with cache verdict (memory/disk/network), load ms, decoded vs displayed size with an oversize/upscale verdict and wasted-KB estimate, error details, and any active simulation overrides. Image HTTP never hits the JS network stack, so this is the only visibility into image loading. Requires @buoy-gg/images on the device.",
2413
+ inputSchema: {
2414
+ deviceId: import_zod14.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2415
+ limit: import_zod14.z.number().optional().describe("Max records to return (newest first). Default 30, max 200."),
2416
+ status: import_zod14.z.enum(["pending", "loading", "loaded", "error"]).optional().describe("Only records in this state (e.g. 'error' for the failure log).")
2417
+ }
2418
+ },
2419
+ async ({ deviceId, limit, status }) => {
2420
+ try {
2421
+ const target = sync.requireProDevice(deviceId);
2422
+ const capped = Math.min(Math.max(limit ?? 30, 1), 200);
2423
+ const list = await sync.callAction(
2424
+ IMAGES_TOOL_ID,
2425
+ "list",
2426
+ { limit: capped, status },
2427
+ target
2428
+ );
2429
+ if (!list) return fail14("Device returned nothing \u2014 is @buoy-gg/images installed in the app?");
2430
+ const md = formatListMarkdown(list, capped);
2431
+ const slim = {
2432
+ stats: list.stats,
2433
+ insights: list.insights,
2434
+ captureStatus: list.captureStatus,
2435
+ globalModes: list.globalModes,
2436
+ records: list.records.map((r) => ({
2437
+ id: r.id,
2438
+ uri: r.uri,
2439
+ lib: r.lib,
2440
+ status: r.status,
2441
+ mounted: r.mounted,
2442
+ cache: r.cache,
2443
+ ms: r.ms,
2444
+ intrinsic: r.intrinsic,
2445
+ layout: r.layout,
2446
+ oversizeFactor: r.oversizeFactor,
2447
+ wastedKB: r.wastedKB,
2448
+ error: r.error,
2449
+ errorCode: r.errorCode,
2450
+ overrideLabel: r.overrideLabel,
2451
+ loadCount: r.loadCount
2452
+ }))
2453
+ };
2454
+ return text12(`${md}
2455
+
2456
+ \`\`\`json
2457
+ ${JSON.stringify(slim)}
2458
+ \`\`\``);
2459
+ } catch (e) {
2460
+ return fail14(errMsg14(e));
2461
+ }
2462
+ }
2463
+ );
2464
+ server.registerTool(
2465
+ "image_action",
2466
+ {
2467
+ description: "Act on one image from get_images (by id): hard_reload (bypass caches + refetch), retry, evict_disk (expo disk entry), flash (red border to locate it on screen), prove_savings (re-encode at displayed size as WebP ON DEVICE and return real byte savings + a previewable file:// uri), or simulation overrides \u2014 force_error (instant offline-safe failure), force_loading (request hangs forever), force_blank, swap_url (requires url), restore (remove override). Returns the record's state after the action so the effect is visible.",
2468
+ inputSchema: {
2469
+ deviceId: import_zod14.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2470
+ id: import_zod14.z.number().describe("Record id from get_images."),
2471
+ action: import_zod14.z.enum([
2472
+ "hard_reload",
2473
+ "retry",
2474
+ "evict_disk",
2475
+ "flash",
2476
+ "prove_savings",
2477
+ "force_error",
2478
+ "force_loading",
2479
+ "force_blank",
2480
+ "swap_url",
2481
+ "restore"
2482
+ ]).describe("What to do."),
2483
+ url: import_zod14.z.string().optional().describe("Replacement URL (swap_url only).")
2484
+ }
2485
+ },
2486
+ async ({ deviceId, id, action, url }) => {
2487
+ try {
2488
+ const target = sync.requireProDevice(deviceId);
2489
+ if (action === "prove_savings") {
2490
+ const savings = await sync.callAction(IMAGES_TOOL_ID, "proveSavings", { id }, target);
2491
+ if (!savings) return fail14("Device returned nothing for proveSavings.");
2492
+ if (!savings.ok) return fail14(savings.message);
2493
+ const parts = [`**prove_savings** on #${id}: ${savings.message}`];
2494
+ if (savings.originalBytes != null)
2495
+ parts.push(`Original: ${Math.round(savings.originalBytes / 1024)}KB (${savings.originalBytesSource})`);
2496
+ if (savings.optimizedBytes != null)
2497
+ parts.push(
2498
+ `Optimized: ${Math.round(savings.optimizedBytes / 1024)}KB \u2014 ${savings.optimizedDims?.width}\xD7${savings.optimizedDims?.height} WebP`
2499
+ );
2500
+ if (savings.savedBytes != null)
2501
+ parts.push(`**Saved: ${Math.round(savings.savedBytes / 1024)}KB (${savings.savedPct}%)**`);
2502
+ if (savings.optimizedUri)
2503
+ parts.push(`Preview in place: image_action {id:${id}, action:"swap_url", url:"${savings.optimizedUri}"}`);
2504
+ return text12(parts.join("\n"));
2505
+ }
2506
+ const entry = ACTION_MAP[action];
2507
+ if (!entry) return fail14(`Unknown action: ${action}`);
2508
+ if (action === "swap_url" && !url) return fail14("swap_url requires the `url` param.");
2509
+ const result = await sync.callAction(
2510
+ IMAGES_TOOL_ID,
2511
+ entry.action,
2512
+ { id, ...entry.params?.(url) ?? {} },
2513
+ target
2514
+ );
2515
+ const settleMs = action === "flash" ? 0 : 1500;
2516
+ if (settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
2517
+ const detail = await sync.callAction(IMAGES_TOOL_ID, "getDetail", { id }, target).catch(() => null);
2518
+ const lines = [
2519
+ `**${action}** on #${id}: ${result?.message ?? (result?.ok === false ? "failed" : "done")}`
2520
+ ];
2521
+ if (detail) {
2522
+ lines.push("");
2523
+ lines.push(
2524
+ `Now: status=${detail.status}${detail.errorCode ? ` (${detail.errorCode})` : ""} \xB7 cache=${cacheLabel(detail)} \xB7 ms=${detail.ms ?? "\u2013"} \xB7 loads=${detail.loadCount}${detail.overrideLabel ? ` \xB7 sim="${detail.overrideLabel}"` : ""}${detail.mounted ? "" : " \xB7 UNMOUNTED"}`
2525
+ );
2526
+ if (detail.error) lines.push(`Error: ${detail.error}`);
2527
+ }
2528
+ return text12(lines.join("\n"));
2529
+ } catch (e) {
2530
+ return fail14(errMsg14(e));
2531
+ }
2532
+ }
2533
+ );
2534
+ server.registerTool(
2535
+ "set_image_simulation",
2536
+ {
2537
+ description: "App-wide image simulation modes: network 'offline' (every NETWORK image load fails immediately, both libs \u2014 bundled assets still load; reload/remount on-screen images to see it), 'cold' (every image bypasses caches \u2014 first-launch behavior, nothing cleared), 'normal' to reset. blankImages renders every image without a source. massAction applies per-image simulations to everything mounted. Also clears the registry or expo caches when asked.",
2538
+ inputSchema: {
2539
+ deviceId: import_zod14.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2540
+ network: import_zod14.z.enum(["normal", "offline", "cold"]).optional().describe("Network simulation mode."),
2541
+ blankImages: import_zod14.z.boolean().optional().describe("Render all images without a source."),
2542
+ massAction: import_zod14.z.enum(["error", "loading", "blank", "reload", "flash", "restore"]).optional().describe("Apply to EVERY mounted image at once: force error/loading/blank overrides, hard-reload all, flash all, or restore all overrides."),
2543
+ clearRegistry: import_zod14.z.boolean().optional().describe("Clear the captured image log."),
2544
+ clearExpoCaches: import_zod14.z.boolean().optional().describe("Clear expo-image memory + disk caches.")
2545
+ }
2546
+ },
2547
+ async ({ deviceId, network, blankImages, massAction, clearRegistry, clearExpoCaches }) => {
2548
+ try {
2549
+ const target = sync.requireProDevice(deviceId);
2550
+ const done = [];
2551
+ if (massAction) {
2552
+ const r = await sync.callAction(IMAGES_TOOL_ID, "massAction", { kind: massAction }, target);
2553
+ done.push(r?.message ?? `mass ${massAction} applied`);
2554
+ }
2555
+ if (network) {
2556
+ await sync.callAction(IMAGES_TOOL_ID, "setNetworkMode", { mode: network }, target);
2557
+ done.push(`network=${network}`);
2558
+ }
2559
+ if (blankImages != null) {
2560
+ await sync.callAction(IMAGES_TOOL_ID, "setBlankImages", { enabled: blankImages }, target);
2561
+ done.push(`blankImages=${blankImages}`);
2562
+ }
2563
+ if (clearRegistry) {
2564
+ await sync.callAction(IMAGES_TOOL_ID, "clearRecords", {}, target);
2565
+ done.push("registry cleared");
2566
+ }
2567
+ if (clearExpoCaches) {
2568
+ const r = await sync.callAction(IMAGES_TOOL_ID, "clearExpoCaches", {}, target);
2569
+ done.push(r?.message ?? "expo caches cleared");
2570
+ }
2571
+ if (done.length === 0) return fail14("Pass at least one of: network, blankImages, massAction, clearRegistry, clearExpoCaches.");
2572
+ return text12(`Applied: ${done.join(" \xB7 ")}`);
2573
+ } catch (e) {
2574
+ return fail14(errMsg14(e));
2575
+ }
2576
+ }
2577
+ );
2578
+ }
2579
+
2147
2580
  // src/server.ts
2148
2581
  var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
2149
2582
 
@@ -2166,6 +2599,15 @@ elements (accessibility-style tree with tap points + testIDs, no screenshot), th
2166
2599
  ({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
2167
2600
  it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
2168
2601
 
2602
+ JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
2603
+ \`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
2604
+ freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
2605
+ Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
2606
+
2607
+ Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
2608
+ expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
2609
+ simulates failures per image; \`set_image_simulation\` flips app-wide offline/cold/blank modes.
2610
+
2169
2611
  For everything else:
2170
2612
  - \`list_tool_actions\` to see what a tool can do,
2171
2613
  - \`get_snapshot\` ONLY when you need a tool's full current state (returns raw, heavy payloads),
@@ -2194,22 +2636,28 @@ function createMcpServer(sync) {
2194
2636
  registerConsoleTools(server, sync);
2195
2637
  registerScreenshotTools(server, sync);
2196
2638
  registerInteractTools(server, sync);
2639
+ registerJsThreadTools(server, sync);
2640
+ registerImagesTools(server, sync);
2197
2641
  return server;
2198
2642
  }
2199
2643
  var deviceIdField = {
2200
- deviceId: import_zod13.z.string().optional().describe(
2644
+ deviceId: import_zod15.z.string().optional().describe(
2201
2645
  'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
2202
2646
  )
2203
2647
  };
2204
2648
 
2205
2649
  // src/init.ts
2650
+ var import_node_child_process2 = require("child_process");
2206
2651
  var import_node_fs3 = __toESM(require("fs"));
2652
+ var import_node_os3 = __toESM(require("os"));
2207
2653
  var import_node_path3 = __toESM(require("path"));
2654
+ var PUBLIC_REGISTRY = "https://registry.npmjs.org";
2655
+ var PROBE_TIMEOUT_MS = 3e3;
2208
2656
  function runInit(opts = {}) {
2209
2657
  const cwd = opts.cwd ?? process.cwd();
2210
2658
  const skillSrcDir = opts.skillSrcDir ?? import_node_path3.default.resolve(__dirname, "../skill");
2211
2659
  const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
2212
- const entry = opts.cliPath ? { type: "stdio", command: "node", args: [opts.cliPath] } : { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
2660
+ const entry = resolveEntry(opts, log);
2213
2661
  const done = [];
2214
2662
  upsertMcpConfig(import_node_path3.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
2215
2663
  if (import_node_fs3.default.existsSync(import_node_path3.default.join(cwd, ".cursor"))) {
@@ -2226,12 +2674,125 @@ function runInit(opts = {}) {
2226
2674
  log(" 1. Restart your editor (or reconnect the MCP server) to load `buoy`.");
2227
2675
  log(" 2. Open your app with Buoy devtools running on a device or simulator.");
2228
2676
  log(' 3. Say "buoy optimize" to start a guided performance pass.');
2229
- if (!opts.cliPath) {
2677
+ if (entry.command === "npx") {
2230
2678
  log("");
2231
2679
  log(`This launches the server via \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
2232
2680
  log(`up to date automatically. To force-refresh, re-run \`npx -y ${PACKAGE_NAME}@latest init\`.`);
2681
+ } else if (!opts.cliPath) {
2682
+ log("");
2683
+ log(`This uses a pinned local install (no network on startup). To update it later,`);
2684
+ log(`re-run init (\`node ${entry.args[0]?.replace(/dist[/\\]cli\.js$/, "") ?? ""}...\` \u2014 or reinstall).`);
2233
2685
  }
2234
2686
  }
2687
+ function resolveEntry(opts, log) {
2688
+ if (opts.cliPath) {
2689
+ return { type: "stdio", command: "node", args: [opts.cliPath] };
2690
+ }
2691
+ const mode = opts.mode ?? "auto";
2692
+ if (mode === "npx") {
2693
+ return npxEntry();
2694
+ }
2695
+ if (mode === "local") {
2696
+ return localEntry(opts, log);
2697
+ }
2698
+ const probe = probeRegistry();
2699
+ if (probe.reachable) {
2700
+ return npxEntry();
2701
+ }
2702
+ log(
2703
+ `\u26A0 npm registry ${probe.registry ? `(${probe.registry}) ` : ""}looks unreachable \u2014 \`npx @latest\` would hang your editor at startup. Falling back to a pinned local install.`
2704
+ );
2705
+ return localEntry(opts, log);
2706
+ }
2707
+ function npxEntry() {
2708
+ return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
2709
+ }
2710
+ function localEntry(opts, log) {
2711
+ const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path3.default.join(import_node_os3.default.homedir(), ".buoy-mcp");
2712
+ const registry = opts.registry ?? PUBLIC_REGISTRY;
2713
+ const cliPath = installLocalCopy(installDir, registry, log);
2714
+ if (cliPath) {
2715
+ return { type: "stdio", command: "node", args: [cliPath] };
2716
+ }
2717
+ log("\u26A0 local install failed \u2014 writing the npx entry instead (see the error above).");
2718
+ return npxEntry();
2719
+ }
2720
+ function installLocalCopy(installDir, registry, log) {
2721
+ const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
2722
+ import_node_fs3.default.mkdirSync(installDir, { recursive: true });
2723
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
2724
+ log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
2725
+ const res = (0, import_node_child_process2.spawnSync)(
2726
+ npm,
2727
+ [
2728
+ "install",
2729
+ spec,
2730
+ "--prefix",
2731
+ installDir,
2732
+ "--registry",
2733
+ registry,
2734
+ `--@buoy-gg:registry=${registry}`,
2735
+ "--no-audit",
2736
+ "--no-fund",
2737
+ "--loglevel=error"
2738
+ ],
2739
+ { stdio: ["ignore", "ignore", "inherit"], encoding: "utf8" }
2740
+ );
2741
+ if (res.status !== 0) {
2742
+ log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
2743
+ return null;
2744
+ }
2745
+ const cliPath = import_node_path3.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
2746
+ if (!import_node_fs3.default.existsSync(cliPath)) {
2747
+ log(`\u26A0 install finished but ${cliPath} is missing.`);
2748
+ return null;
2749
+ }
2750
+ return cliPath;
2751
+ }
2752
+ function probeRegistry() {
2753
+ const registry = effectiveRegistry();
2754
+ if (!registry) return { reachable: false, registry: null };
2755
+ if (/^https?:\/\/registry\.npmjs\.org\/?$/i.test(registry)) {
2756
+ return { reachable: true, registry };
2757
+ }
2758
+ return { reachable: hostReachable(registry), registry };
2759
+ }
2760
+ function effectiveRegistry() {
2761
+ const scoped = npmConfigGet("@buoy-gg:registry");
2762
+ if (scoped) return scoped;
2763
+ const global = npmConfigGet("registry");
2764
+ return global ?? PUBLIC_REGISTRY;
2765
+ }
2766
+ function npmConfigGet(key) {
2767
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
2768
+ const res = (0, import_node_child_process2.spawnSync)(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
2769
+ if (res.status !== 0 || typeof res.stdout !== "string") return null;
2770
+ const val = res.stdout.trim();
2771
+ if (!val || val === "undefined" || val === "null") return null;
2772
+ return val;
2773
+ }
2774
+ function hostReachable(registry) {
2775
+ const done = (0, import_node_child_process2.spawnSync)(
2776
+ process.execPath,
2777
+ ["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS)],
2778
+ { encoding: "utf8", timeout: PROBE_TIMEOUT_MS + 2e3 }
2779
+ );
2780
+ return done.status === 0;
2781
+ }
2782
+ var REACHABILITY_PROBE = `
2783
+ const url = process.argv[1];
2784
+ const ms = Number(process.argv[2]) || 3000;
2785
+ const c = new AbortController();
2786
+ const t = setTimeout(() => c.abort(), ms);
2787
+ fetch(url, { method: 'HEAD', signal: c.signal })
2788
+ .then(() => { clearTimeout(t); process.exit(0); })
2789
+ .catch(() => {
2790
+ // A HEAD may be rejected while the host is up; a bare TCP-level failure is
2791
+ // what we care about. fetch throwing a TypeError = network unreachable.
2792
+ clearTimeout(t);
2793
+ process.exit(1);
2794
+ });
2795
+ `;
2235
2796
  function upsertMcpConfig(file, key, entry, cwd, done) {
2236
2797
  let json3 = {};
2237
2798
  if (import_node_fs3.default.existsSync(file)) {
@@ -2284,6 +2845,9 @@ function rel(file, cwd) {
2284
2845
  }
2285
2846
 
2286
2847
  // src/cli.ts
2848
+ console.log = (...args) => console.error(...args);
2849
+ console.info = (...args) => console.error(...args);
2850
+ console.debug = (...args) => console.error(...args);
2287
2851
  async function main() {
2288
2852
  const portEnv = process.env.BUOY_BROKER_PORT;
2289
2853
  const sync = new SyncClient({
@@ -2315,7 +2879,13 @@ async function main() {
2315
2879
  }
2316
2880
  if (process.argv[2] === "init") {
2317
2881
  try {
2318
- runInit();
2882
+ const args = process.argv.slice(3);
2883
+ let mode = "auto";
2884
+ if (args.includes("--local")) mode = "local";
2885
+ else if (args.includes("--npx")) mode = "npx";
2886
+ const ri = args.indexOf("--registry");
2887
+ const registry = ri !== -1 ? args[ri + 1] : void 0;
2888
+ runInit({ mode, registry });
2319
2889
  process.exit(0);
2320
2890
  } catch (err) {
2321
2891
  process.stderr.write(`[buoy-mcp] init failed: ${err?.message ?? err}
package/dist/cli.mjs CHANGED
@@ -3,10 +3,13 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-XIN7JPXX.mjs";
6
+ } from "./chunk-EC6YNLYA.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ console.log = (...args) => console.error(...args);
11
+ console.info = (...args) => console.error(...args);
12
+ console.debug = (...args) => console.error(...args);
10
13
  async function main() {
11
14
  const portEnv = process.env.BUOY_BROKER_PORT;
12
15
  const sync = new SyncClient({
@@ -38,7 +41,13 @@ async function main() {
38
41
  }
39
42
  if (process.argv[2] === "init") {
40
43
  try {
41
- runInit();
44
+ const args = process.argv.slice(3);
45
+ let mode = "auto";
46
+ if (args.includes("--local")) mode = "local";
47
+ else if (args.includes("--npx")) mode = "npx";
48
+ const ri = args.indexOf("--registry");
49
+ const registry = ri !== -1 ? args[ri + 1] : void 0;
50
+ runInit({ mode, registry });
42
51
  process.exit(0);
43
52
  } catch (err) {
44
53
  process.stderr.write(`[buoy-mcp] init failed: ${err?.message ?? err}