@buoy-gg/mcp 7.0.21 → 7.0.23

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
@@ -37,7 +37,12 @@ var import_sync_broker = require("@buoy-gg/sync-broker");
37
37
  var BROKER_PORT = import_sync_broker.DEFAULT_BROKER_PORT;
38
38
  var BROKER_URL = `http://localhost:${BROKER_PORT}`;
39
39
  var DEFAULT_ACTION_TIMEOUT_MS = 15e3;
40
+ var UNRESPONSIVE_ACTION_TIMEOUT_MS = 2500;
41
+ var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS = 1200;
42
+ var RECOVERY_PROBE_MS = 2e4;
43
+ var UNRESPONSIVE_AFTER_MS = 3e4;
40
44
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 5e3;
45
+ var DEVICE_RETENTION_MS = 15 * 6e4;
41
46
  var DASHBOARD_NAME = "Dashboard";
42
47
  var ALL_DEVICES = "All";
43
48
 
@@ -62,6 +67,16 @@ var SyncClient = class {
62
67
  this.pendingActions = /* @__PURE__ */ new Map();
63
68
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
64
69
  this.snapshotWaiters = /* @__PURE__ */ new Map();
70
+ /** When each cached snapshot arrived, keyed by snapKey (staleness reporting). */
71
+ this.snapshotAt = /* @__PURE__ */ new Map();
72
+ /** Liveness per device — see {@link noteTimeout}. */
73
+ this.health = /* @__PURE__ */ new Map();
74
+ /** Dev-server origin per device — see {@link noteDevServerOrigin}. */
75
+ this.devServerOrigins = /* @__PURE__ */ new Map();
76
+ /** Devices whose origin probe already ran (a production bundle has none). */
77
+ this.originProbed = /* @__PURE__ */ new Set();
78
+ /** Last time each device appeared in the broker's device list. */
79
+ this.lastSeen = /* @__PURE__ */ new Map();
65
80
  this.port = opts.port ?? BROKER_PORT;
66
81
  this.url = opts.url ?? (opts.port ? `http://localhost:${opts.port}` : BROKER_URL);
67
82
  this.actionTimeoutMs = opts.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
@@ -121,13 +136,19 @@ var SyncClient = class {
121
136
  });
122
137
  socket.on("devtool-capabilities", (msg) => {
123
138
  this.capabilities.set(msg.persistentDeviceId, msg);
139
+ void this.learnDevServerOrigin(msg.persistentDeviceId);
124
140
  });
125
141
  socket.on("all-devices-update", (list) => {
126
- if (Array.isArray(list)) this.devices = list;
142
+ if (!Array.isArray(list)) return;
143
+ this.devices = list;
144
+ const now = Date.now();
145
+ for (const d of list) if (d.deviceId) this.lastSeen.set(d.deviceId, now);
146
+ this.pruneStaleDevices();
127
147
  });
128
148
  socket.on("devtool-sync", (msg) => {
129
149
  const key = snapKey(msg.persistentDeviceId, msg.toolId);
130
150
  this.snapshots.set(key, msg.payload);
151
+ this.snapshotAt.set(key, Date.now());
131
152
  const waiters = this.snapshotWaiters.get(key);
132
153
  if (waiters && waiters.length) {
133
154
  this.snapshotWaiters.set(key, []);
@@ -143,6 +164,37 @@ var SyncClient = class {
143
164
  });
144
165
  });
145
166
  }
167
+ /**
168
+ * Drop everything remembered about devices that have been gone a while.
169
+ *
170
+ * Deliberately NOT on disconnect: a crashed app often drops off the broker,
171
+ * and its cached console snapshot is the crash evidence — throwing that away
172
+ * the instant it disconnects would delete the answer. But device ids change
173
+ * with every simulator erase and fresh dev client, and each one can hold
174
+ * multi-MB snapshots, so a long-lived server has to forget eventually.
175
+ */
176
+ pruneStaleDevices() {
177
+ const cutoff = Date.now() - DEVICE_RETENTION_MS;
178
+ for (const [deviceId, seen] of this.lastSeen) {
179
+ if (seen > cutoff) continue;
180
+ if (this.devices.some((d) => d.deviceId === deviceId)) continue;
181
+ this.lastSeen.delete(deviceId);
182
+ this.capabilities.delete(deviceId);
183
+ this.health.delete(deviceId);
184
+ this.devServerOrigins.delete(deviceId);
185
+ this.originProbed.delete(deviceId);
186
+ const prefix = `${deviceId}::`;
187
+ for (const key of [...this.snapshots.keys()]) {
188
+ if (key.startsWith(prefix)) {
189
+ this.snapshots.delete(key);
190
+ this.snapshotAt.delete(key);
191
+ }
192
+ }
193
+ for (const key of [...this.watching.keys()]) {
194
+ if (key.startsWith(prefix)) this.watching.delete(key);
195
+ }
196
+ }
197
+ }
146
198
  /** Disconnect and tear down a spawned broker, if any. */
147
199
  async stop() {
148
200
  for (const [, p] of this.pendingActions) {
@@ -150,6 +202,8 @@ var SyncClient = class {
150
202
  p.reject(new Error("SyncClient stopped"));
151
203
  }
152
204
  this.pendingActions.clear();
205
+ for (const [, waiters] of this.snapshotWaiters) for (const w of waiters) w(void 0);
206
+ this.snapshotWaiters.clear();
153
207
  this.socket?.close();
154
208
  this.socket = null;
155
209
  if (this.broker) {
@@ -170,6 +224,7 @@ var SyncClient = class {
170
224
  platform: d.platform,
171
225
  isConnected: d.isConnected !== false,
172
226
  isPro: d.isPro === true,
227
+ tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
173
228
  tools: caps?.tools ?? []
174
229
  };
175
230
  });
@@ -193,11 +248,19 @@ var SyncClient = class {
193
248
  resolveDevice(deviceId) {
194
249
  if (deviceId) return deviceId;
195
250
  const connected = this.connectedDevices();
196
- if (connected.length === 1) return connected[0].deviceId;
197
251
  if (connected.length === 0) {
198
252
  throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.");
199
253
  }
200
- const list = connected.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
254
+ if (connected.length === 1) return connected[0].deviceId;
255
+ const usable = connected.filter((d) => d.tools.length > 0);
256
+ if (usable.length === 1) return usable[0].deviceId;
257
+ if (usable.length === 0) {
258
+ const names = connected.map((d) => d.deviceName).join(", ");
259
+ throw new Error(
260
+ `Connected devices (${names}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`
261
+ );
262
+ }
263
+ const list = usable.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
201
264
  throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${list}`);
202
265
  }
203
266
  /**
@@ -211,17 +274,153 @@ var SyncClient = class {
211
274
  * is blocked.
212
275
  */
213
276
  requireProDevice(deviceId) {
214
- const proMsg = (name) => `Buoy Pro is required to use the MCP.${name ? ` The connected app "${name}" is on the free tier.` : ""} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
277
+ const tierNote = (dev2) => {
278
+ if (!dev2) return "";
279
+ return dev2.tier === "anonymous" ? ` The connected app "${dev2.deviceName}" has no license key at all.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
280
+ };
281
+ const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
282
+ dev2
283
+ )} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
215
284
  if (deviceId === ALL_DEVICES) {
216
285
  if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
217
286
  return ALL_DEVICES;
218
287
  }
219
288
  const target = this.resolveDevice(deviceId);
220
289
  const dev = this.connectedDevices().find((d) => d.deviceId === target);
221
- if (!dev?.isPro) throw new Error(proMsg(dev?.deviceName));
290
+ if (!dev?.isPro) throw new Error(proMsg(dev));
222
291
  return target;
223
292
  }
224
293
  // ----------------------------------------------------------------
294
+ // Device liveness
295
+ // ----------------------------------------------------------------
296
+ healthOf(deviceId) {
297
+ let h = this.health.get(deviceId);
298
+ if (!h) {
299
+ h = { consecutiveTimeouts: 0, lastOkAt: 0, lastFullBudgetAt: 0 };
300
+ this.health.set(deviceId, h);
301
+ }
302
+ return h;
303
+ }
304
+ /** The device answered — it's alive. */
305
+ noteOk(deviceId) {
306
+ const h = this.healthOf(deviceId);
307
+ h.consecutiveTimeouts = 0;
308
+ h.lastOkAt = Date.now();
309
+ }
310
+ /**
311
+ * The device didn't answer. A crashed app never answers again, so after the
312
+ * first miss we stop paying the full timeout on every subsequent call.
313
+ */
314
+ noteTimeout(deviceId) {
315
+ this.healthOf(deviceId).consecutiveTimeouts += 1;
316
+ }
317
+ /** True once a device has missed a call and hasn't answered since. */
318
+ isUnresponsive(deviceId) {
319
+ return this.healthOf(deviceId).consecutiveTimeouts > 0;
320
+ }
321
+ /**
322
+ * Full timeout normally; a short one for a device already not answering.
323
+ *
324
+ * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
325
+ * full budget again. Without it the flag is self-sustaining — a device whose
326
+ * work legitimately takes longer than the short budget (a big snapshot, a
327
+ * slow simulator) can never answer, so it can never clear the flag, and every
328
+ * later reply is a confident "most likely CRASHED" about a healthy app.
329
+ */
330
+ timeoutFor(deviceId) {
331
+ if (!this.isUnresponsive(deviceId)) return this.actionTimeoutMs;
332
+ const h = this.healthOf(deviceId);
333
+ if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
334
+ h.lastFullBudgetAt = Date.now();
335
+ return this.actionTimeoutMs;
336
+ }
337
+ return UNRESPONSIVE_ACTION_TIMEOUT_MS;
338
+ }
339
+ /**
340
+ * Plain-language diagnosis for a device that isn't answering, including the
341
+ * app's last words if the console tool got a crash flush out in time.
342
+ *
343
+ * A silent timeout is the worst possible answer here: the app is on a redbox,
344
+ * the reason is already captured, and without this the caller reads "timed
345
+ * out" and keeps retrying the same dead device.
346
+ */
347
+ unresponsiveNote(deviceId) {
348
+ const h = this.healthOf(deviceId);
349
+ const since = h.lastOkAt ? Math.round((Date.now() - h.lastOkAt) / 1e3) : null;
350
+ const crashed = h.consecutiveTimeouts >= 2 || h.lastOkAt === 0 || Date.now() - h.lastOkAt > UNRESPONSIVE_AFTER_MS;
351
+ const lines = [
352
+ `The app on "${deviceId}" is not responding (${h.consecutiveTimeouts} call${h.consecutiveTimeouts === 1 ? "" : "s"} unanswered${since != null ? `, last answered ${since}s ago` : ""}).`,
353
+ crashed ? "It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded." : "It may be busy, mid-reload, or crashing."
354
+ ];
355
+ const fatal = this.lastFatalFor(deviceId);
356
+ if (fatal) lines.push(`Last captured error: ${fatal}`);
357
+ lines.push(
358
+ fatal ? "Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover." : "Try get_console (served from the last sync) for the app's last words, then reload_app to recover."
359
+ );
360
+ return lines.join(" ");
361
+ }
362
+ /**
363
+ * The newest error line the console tool synced for this device, if any.
364
+ * Reads the cached console snapshot rather than the device (which, by the
365
+ * time this matters, is not answering).
366
+ */
367
+ lastFatalFor(deviceId) {
368
+ const snap = this.snapshots.get(snapKey(deviceId, "console"));
369
+ const entries = snap?.entries;
370
+ if (!Array.isArray(entries)) return null;
371
+ for (let i = entries.length - 1; i >= 0; i--) {
372
+ const e = entries[i];
373
+ if (e?.fatal || e?.level === "error") {
374
+ const msg = (e.message ?? "").split("\n")[0];
375
+ if (msg) return msg.length > 300 ? `${msg.slice(0, 300)}\u2026` : msg;
376
+ }
377
+ }
378
+ return null;
379
+ }
380
+ /** Age of the cached snapshot for a tool, in ms (null = never received). */
381
+ snapshotAgeMs(toolId, deviceId) {
382
+ const at = this.snapshotAt.get(snapKey(deviceId, toolId));
383
+ return at ? Date.now() - at : null;
384
+ }
385
+ /**
386
+ * Remember where a device's bundle came from, reported by core's `app`
387
+ * adapter while the app is healthy. It is the only way to reload an app that
388
+ * has crashed: the in-app reload needs the React tree that the crash
389
+ * destroyed, but the dev server's reload is handled natively.
390
+ */
391
+ noteDevServerOrigin(deviceId, origin) {
392
+ if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
393
+ this.devServerOrigins.set(deviceId, origin);
394
+ }
395
+ }
396
+ /**
397
+ * Ask a freshly-announced device where its bundle came from, once.
398
+ *
399
+ * Deliberately eager: this is the piece of information that can ONLY be
400
+ * collected while the app is healthy, and is only ever needed after it has
401
+ * stopped answering. One cheap round-trip per device buys the ability to
402
+ * recover a crashed app instead of telling the caller to go do it by hand.
403
+ */
404
+ async learnDevServerOrigin(deviceId) {
405
+ if (this.devServerOrigins.has(deviceId) || this.originProbed.has(deviceId)) return;
406
+ this.originProbed.add(deviceId);
407
+ const hasApp = this.capabilities.get(deviceId)?.tools?.some((t) => t.toolId === "app");
408
+ if (!hasApp) return;
409
+ try {
410
+ const { payload } = await this.getSnapshot("app", deviceId, { timeoutMs: 4e3, silent: true });
411
+ this.noteDevServerOrigin(deviceId, payload?.devServerOrigin);
412
+ } catch {
413
+ }
414
+ }
415
+ /** The dev-server origin last reported by this device, if any. */
416
+ devServerOrigin(deviceId) {
417
+ const known = this.devServerOrigins.get(deviceId);
418
+ if (known) return known;
419
+ const snap = this.snapshots.get(snapKey(deviceId, "app"));
420
+ if (typeof snap?.devServerOrigin === "string") return snap.devServerOrigin;
421
+ return null;
422
+ }
423
+ // ----------------------------------------------------------------
225
424
  // Watch + snapshots
226
425
  // ----------------------------------------------------------------
227
426
  /**
@@ -292,13 +491,36 @@ var SyncClient = class {
292
491
  const target = this.resolveDevice(deviceId);
293
492
  const key = snapKey(target, toolId);
294
493
  this.ensureWatching(toolId, target);
295
- const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
494
+ let budget = opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
495
+ if (opts.timeoutMs === void 0 && this.isUnresponsive(target)) {
496
+ const h = this.healthOf(target);
497
+ if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
498
+ h.lastFullBudgetAt = Date.now();
499
+ } else {
500
+ budget = UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS;
501
+ }
502
+ }
503
+ const next = this.waitForSnapshot(key, budget);
296
504
  this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
297
505
  const payload = await next;
298
- if (payload !== void 0) return { deviceId: target, payload };
506
+ if (payload !== void 0) {
507
+ if (!opts.silent) this.noteOk(target);
508
+ return { deviceId: target, payload };
509
+ }
510
+ if (!opts.silent) this.noteTimeout(target);
299
511
  const cached = this.snapshots.get(key);
300
- if (cached !== void 0) return { deviceId: target, payload: cached };
301
- throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
512
+ if (cached !== void 0) {
513
+ return { deviceId: target, payload: cached, stale: this.staleNote(toolId, target) };
514
+ }
515
+ throw new Error(
516
+ `Timed out waiting for "${toolId}" state from ${target} (is the tool active?). ${this.unresponsiveNote(target)}`
517
+ );
518
+ }
519
+ /** Header line for data served from cache because the device went quiet. */
520
+ staleNote(toolId, deviceId) {
521
+ const age = this.snapshotAgeMs(toolId, deviceId);
522
+ const ageText = age == null ? "an earlier sync" : `${Math.round(age / 1e3)}s ago`;
523
+ return `\u26A0\uFE0F Served from the last sync (${ageText}) \u2014 ${this.unresponsiveNote(deviceId)}`;
302
524
  }
303
525
  waitForSnapshot(key, timeoutMs) {
304
526
  return new Promise((resolve) => {
@@ -327,11 +549,16 @@ var SyncClient = class {
327
549
  const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
328
550
  if (!this.socket) throw new Error("SyncClient is not connected");
329
551
  const requestId = (0, import_node_crypto.randomUUID)();
330
- const timeoutMs = opts.timeoutMs ?? this.actionTimeoutMs;
552
+ const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target);
331
553
  const result = await new Promise((resolve, reject) => {
332
554
  const timer = setTimeout(() => {
333
555
  this.pendingActions.delete(requestId);
334
- reject(new Error(`Action "${toolId}.${action}" timed out after ${timeoutMs}ms`));
556
+ if (target !== ALL_DEVICES && !opts.silent) this.noteTimeout(target);
557
+ reject(
558
+ new Error(
559
+ `Action "${toolId}.${action}" got no answer in ${timeoutMs}ms. ` + (target === ALL_DEVICES ? "No device responded." : this.unresponsiveNote(target))
560
+ )
561
+ );
335
562
  }, timeoutMs);
336
563
  this.pendingActions.set(requestId, { resolve, reject, timer });
337
564
  this.socket.emit("devtool-action", {
@@ -342,6 +569,7 @@ var SyncClient = class {
342
569
  params: params ?? {}
343
570
  });
344
571
  });
572
+ if (target !== ALL_DEVICES && !opts.silent) this.noteOk(target);
345
573
  if (!result.ok) {
346
574
  throw new Error(result.error || `Action "${toolId}.${action}" failed on device`);
347
575
  }
@@ -373,7 +601,7 @@ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
373
601
 
374
602
  // src/server.ts
375
603
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
376
- var import_zod19 = require("zod");
604
+ var import_zod22 = require("zod");
377
605
 
378
606
  // src/tools/generic.ts
379
607
  var import_zod = require("zod");
@@ -384,7 +612,7 @@ var import_node_os = __toESM(require("os"));
384
612
  var import_node_path = __toESM(require("path"));
385
613
 
386
614
  // src/version.ts
387
- var VERSION = "7.0.21" ? "7.0.21" : "0.0.0-dev";
615
+ var VERSION = "7.0.23" ? "7.0.23" : "0.0.0-dev";
388
616
  var PACKAGE_NAME = "@buoy-gg/mcp";
389
617
 
390
618
  // src/update/checkForUpdate.ts
@@ -475,11 +703,13 @@ function registerGenericTools(server, sync) {
475
703
  ...updateNotice ? { updateAvailable: updateNotice } : {}
476
704
  });
477
705
  }
478
- const anyPro = devices.some((d) => d.isConnected && d.isPro);
706
+ const connected = devices.filter((d) => d.isConnected);
707
+ const anyPro = connected.some((d) => d.isPro);
708
+ const anySignedIn = connected.some((d) => d.tier !== "anonymous");
479
709
  return json({
480
710
  devices,
481
711
  ...anyPro ? {} : {
482
- hint: "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them."
712
+ hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them." : "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them."
483
713
  },
484
714
  ...updateNotice ? { updateAvailable: updateNotice } : {}
485
715
  });
@@ -558,6 +788,14 @@ var PERF_TOOL_ID = "perf-monitor";
558
788
 
559
789
  // src/tools/packs/perfRanking.ts
560
790
  var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
791
+ var WARMUP_CASE_NAME = "__batch_warmup__";
792
+ var THERMAL_MARK = {
793
+ nominal: "\u{1F7E2} nominal",
794
+ fair: "\u{1F7E1} fair",
795
+ serious: "\u{1F7E0} serious",
796
+ critical: "\u{1F534} critical",
797
+ unknown: "\u2014"
798
+ };
561
799
  var FALLBACK = {
562
800
  perCaseDurationMs: 5e3,
563
801
  settleMs: 600,
@@ -617,6 +855,7 @@ function buildAutomationConfig(input, base = {}) {
617
855
  runsPerCase,
618
856
  coolDownMs,
619
857
  discardWarmupRuns,
858
+ discardWarmupCase: input.discardWarmupCase ?? base.discardWarmupCase ?? true,
620
859
  shuffleCases,
621
860
  reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
622
861
  captureRenders: input.captureRenders ?? base.captureRenders ?? true
@@ -661,7 +900,9 @@ function computeFlags(r) {
661
900
  function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
662
901
  const entries = index.filter((e) => e.batchId === batchId);
663
902
  const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
664
- const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
903
+ const indexes = Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort(
904
+ (a, b) => a - b
905
+ );
665
906
  const medianForCase = (batchIndex) => {
666
907
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
667
908
  if (runs.length === 0) return void 0;
@@ -671,7 +912,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
671
912
  const rows = indexes.map((batchIndex) => {
672
913
  const median = medianForCase(batchIndex);
673
914
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
674
- const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
915
+ const caseName = median?.name ?? casesOrder?.[batchIndex]?.name ?? `case ${batchIndex + 1}`;
675
916
  const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
676
917
  const failed = Boolean(failureReason) || !median;
677
918
  const base = {
@@ -690,11 +931,13 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
690
931
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
691
932
  deltaUiFps: null,
692
933
  deltaJsFps: null,
934
+ ...median?.thermalState ? { thermalState: median.thermalState } : {},
693
935
  renderCommits: num(median?.renderCommits),
936
+ renderWasted: median?.renderWasted,
694
937
  topRenderers: median?.topRenderers ?? []
695
938
  };
696
939
  return { ...base, flags: failed ? [] : computeFlags(base) };
697
- });
940
+ }).filter((r) => r.caseName !== WARMUP_CASE_NAME).map((r, i) => ({ ...r, batchIndex: i }));
698
941
  const baseline = rows[0];
699
942
  if (baseline) {
700
943
  for (const r of rows) {
@@ -748,18 +991,33 @@ function formatRankingMarkdown(ranking) {
748
991
  `**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`
749
992
  );
750
993
  lines.push("");
751
- lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
752
- lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
994
+ const anyThermal = ranking.rows.some((r) => r.thermalState);
995
+ const thermalHead = anyThermal ? " Thermal |" : "";
996
+ const thermalRule = anyThermal ? "-------|" : "";
997
+ lines.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${thermalHead} Flags |`);
998
+ lines.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${thermalRule}-------|`);
753
999
  for (const r of ranking.rows) {
1000
+ const thermalCell = anyThermal ? ` ${THERMAL_MARK[r.thermalState ?? "unknown"] ?? "\u2014"} |` : "";
754
1001
  if (r.failed) {
755
- lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
1002
+ lines.push(
1003
+ `| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${thermalCell} \u274C FAILED (${r.failureReason}) |`
1004
+ );
756
1005
  continue;
757
1006
  }
758
1007
  const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
759
1008
  lines.push(
760
1009
  `| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
761
1010
  r.deltaJsFps
762
- )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
1011
+ )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} |${thermalCell} ${flagCell} |`
1012
+ );
1013
+ }
1014
+ const throttled = ranking.rows.filter(
1015
+ (r) => r.thermalState === "serious" || r.thermalState === "critical"
1016
+ );
1017
+ if (throttled.length) {
1018
+ lines.push("");
1019
+ lines.push(
1020
+ `**\u{1F525} Thermally throttled: ${throttled.map((r) => r.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`
763
1021
  );
764
1022
  }
765
1023
  lines.push("");
@@ -781,13 +1039,18 @@ function formatRankingMarkdown(ranking) {
781
1039
  );
782
1040
  }
783
1041
  const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
784
- const top = r.topRenderers.map((t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms)`).join(", ");
1042
+ const top = r.topRenderers.map(
1043
+ (t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms${t.cause ? `, ${t.cause}` : ""})`
1044
+ ).join(", ");
785
1045
  const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
786
- return `- **${r.caseName}** \u2014 ${[commits, top].filter(Boolean).join(": ")}`;
1046
+ const wasted = r.renderWasted != null ? `${r.renderWasted} wasted` : "";
1047
+ return `- **${r.caseName}** \u2014 ${[commits, wasted, top].filter(Boolean).join(": ")}`;
787
1048
  });
788
1049
  if (renderLines.length) {
789
1050
  lines.push("");
790
- lines.push("**Renders (median run)** \u2014 heaviest components by render self-time:");
1051
+ lines.push(
1052
+ "**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream."
1053
+ );
791
1054
  lines.push(...renderLines);
792
1055
  }
793
1056
  lines.push("");
@@ -810,7 +1073,20 @@ function compareReportStats(a, b) {
810
1073
  cpuUsage: ch(a.stats.cpuUsage, b.stats.cpuUsage),
811
1074
  memoryUsage: ch(a.stats.memoryUsage, b.stats.memoryUsage),
812
1075
  jsJankFrames: { a: a.stats.jsJankFrames, b: b.stats.jsJankFrames, delta: b.stats.jsJankFrames - a.stats.jsJankFrames },
813
- uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames }
1076
+ uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames },
1077
+ ...compareRenders(a.renders, b.renders)
1078
+ };
1079
+ }
1080
+ function compareRenders(a, b) {
1081
+ if (!a && !b) return null;
1082
+ const n = (v) => v ?? 0;
1083
+ return {
1084
+ renders: {
1085
+ total: { a: n(a?.totalRenders), b: n(b?.totalRenders), delta: n(b?.totalRenders) - n(a?.totalRenders) },
1086
+ commits: { a: n(a?.totalCommits), b: n(b?.totalCommits), delta: n(b?.totalCommits) - n(a?.totalCommits) },
1087
+ wasted: { a: n(a?.wastedRenders), b: n(b?.wastedRenders), delta: n(b?.wastedRenders) - n(a?.wastedRenders) },
1088
+ causes: { a: a?.causes ?? {}, b: b?.causes ?? {} }
1089
+ }
814
1090
  };
815
1091
  }
816
1092
  function round(n, d) {
@@ -1060,7 +1336,7 @@ ${JSON.stringify(summary, null, 2)}
1060
1336
  async ({ deviceId, maxWaitMs }) => {
1061
1337
  const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
1062
1338
  `);
1063
- const cap = maxWaitMs ?? 6e5;
1339
+ const cap3 = maxWaitMs ?? 6e5;
1064
1340
  try {
1065
1341
  const target = sync.requireProDevice(deviceId);
1066
1342
  const start = Date.now();
@@ -1068,7 +1344,7 @@ ${JSON.stringify(summary, null, 2)}
1068
1344
  let idleStreak = 0;
1069
1345
  let lastCount = -1;
1070
1346
  let resolved = null;
1071
- while (Date.now() - start < cap) {
1347
+ while (Date.now() - start < cap3) {
1072
1348
  let snap2;
1073
1349
  try {
1074
1350
  snap2 = await readPerfSnapshot(sync, target);
@@ -1153,7 +1429,7 @@ ${JSON.stringify(ranking, null, 2)}
1153
1429
  server.registerTool(
1154
1430
  "compare_reports",
1155
1431
  {
1156
- description: "Compare two saved benchmark runs head-to-head (avg + p95 for FPS/CPU/memory, jank, with deltas).",
1432
+ description: "Compare two saved benchmark runs head-to-head: avg + p95 for FPS/CPU/memory, jank, AND render counts \u2014 total, commits, and wasted (parent cascades + identity-only prop churn) \u2014 each with deltas. Use this to prove a memoization or re-render fix actually landed: the wasted-render delta is the number that says so.",
1157
1433
  inputSchema: {
1158
1434
  reportIdA: import_zod2.z.string().describe("First (baseline) report id."),
1159
1435
  reportIdB: import_zod2.z.string().describe("Second report id to compare against the baseline."),
@@ -1171,8 +1447,8 @@ ${JSON.stringify(ranking, null, 2)}
1171
1447
  if (!b) return fail2(`Report "${reportIdB}" not found.`);
1172
1448
  return json2(
1173
1449
  compareReportStats(
1174
- { name: a.metadata.name, stats: a.stats },
1175
- { name: b.metadata.name, stats: b.stats }
1450
+ { name: a.metadata.name, stats: a.stats, renders: a.renders },
1451
+ { name: b.metadata.name, stats: b.stats, renders: b.renders }
1176
1452
  )
1177
1453
  );
1178
1454
  } catch (e) {
@@ -2185,13 +2461,13 @@ function registerStorageTools(server, sync) {
2185
2461
  const target = sync.requireProDevice(args.deviceId);
2186
2462
  const pattern = args.pattern?.toLowerCase();
2187
2463
  const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
2188
- const matches = (k) => !pattern || k.toLowerCase().includes(pattern);
2464
+ const matches2 = (k) => !pattern || k.toLowerCase().includes(pattern);
2189
2465
  const sections = [];
2190
2466
  const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
2191
2467
  if (!Array.isArray(allKeys)) {
2192
2468
  return fail9("No storage result from the device. The storage tool may be inactive.");
2193
2469
  }
2194
- const asyncKeys = allKeys.filter(matches).slice(0, limit);
2470
+ const asyncKeys = allKeys.filter(matches2).slice(0, limit);
2195
2471
  if (asyncKeys.length > 0) {
2196
2472
  let lines;
2197
2473
  if (args.includeValues) {
@@ -2206,7 +2482,7 @@ ${lines.join("\n")}`);
2206
2482
  }
2207
2483
  const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
2208
2484
  for (const inst of mmkv ?? []) {
2209
- const entries = inst.entries.filter((e) => matches(e.key)).slice(0, limit);
2485
+ const entries = inst.entries.filter((e) => matches2(e.key)).slice(0, limit);
2210
2486
  if (entries.length === 0) continue;
2211
2487
  const lines = entries.map(
2212
2488
  (e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate2(e.value)}` : `- \`${e.key}\``
@@ -2216,7 +2492,7 @@ ${lines.join("\n")}`);
2216
2492
  ${lines.join("\n")}`);
2217
2493
  }
2218
2494
  const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
2219
- const secureKeys = (secure ?? []).filter((d) => matches(d.key)).slice(0, limit);
2495
+ const secureKeys = (secure ?? []).filter((d) => matches2(d.key)).slice(0, limit);
2220
2496
  if (secureKeys.length > 0) {
2221
2497
  const lines = await Promise.all(
2222
2498
  secureKeys.map(async (d) => {
@@ -2384,7 +2660,10 @@ function registerConsoleTools(server, sync) {
2384
2660
  async (args) => {
2385
2661
  try {
2386
2662
  const target = sync.requireProDevice(args.deviceId);
2387
- const { payload } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
2663
+ const { payload, stale } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
2664
+ const staleHeader = stale ? `${stale}
2665
+
2666
+ ` : "";
2388
2667
  const p = Array.isArray(payload) ? { entries: payload } : payload ?? {};
2389
2668
  const all = Array.isArray(p.entries) ? p.entries : [];
2390
2669
  const minSev = args.level ? SEVERITY[args.level] : 0;
@@ -2396,12 +2675,12 @@ function registerConsoleTools(server, sync) {
2396
2675
  const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 50;
2397
2676
  const shown = sorted.slice(-limit);
2398
2677
  if (shown.length === 0) {
2399
- return text10(`No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
2678
+ return text10(`${staleHeader}No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
2400
2679
  }
2401
2680
  const lines = shown.map((e) => `${hhmmss(e.timestamp)} [${TAG[e.level] ?? e.level}] ${e.message}`);
2402
2681
  const levelNote = args.level ? `, level \u2265 ${args.level}` : "";
2403
2682
  const header = `**Console \u2014 ${all.length} entries (showing ${shown.length}${levelNote}):**`;
2404
- return text10(`${header}
2683
+ return text10(`${staleHeader}${header}
2405
2684
 
2406
2685
  \`\`\`
2407
2686
  ${lines.join("\n")}
@@ -2425,13 +2704,19 @@ function kb(bytes) {
2425
2704
  return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
2426
2705
  }
2427
2706
  function formatOutcome(outcome) {
2428
- const lines = Object.entries(outcome.results).map(([sid, r]) => {
2429
- const skips = r.skipped.slice(0, 3).map((s) => `
2707
+ const lines = Object.entries(outcome.results).map(([sid, r2]) => {
2708
+ const skips = r2.skipped.slice(0, 3).map((s) => `
2430
2709
  \xB7 ${s.id}: ${s.reason}`).join("");
2431
- const warns = r.warnings.slice(0, 3).map((w) => `
2710
+ const warns = r2.warnings.slice(0, 3).map((w) => `
2432
2711
  \u26A0 ${w}`).join("");
2433
- return ` ${r.ok ? "\u2713" : "\u2715"} ${sid} \u2014 applied ${r.applied}${r.skipped.length ? `, skipped ${r.skipped.length}` : ""}${skips}${warns}`;
2712
+ return ` ${r2.ok ? "\u2713" : "\u2715"} ${sid} \u2014 applied ${r2.applied}${r2.skipped.length ? `, skipped ${r2.skipped.length}` : ""}${skips}${warns}`;
2434
2713
  });
2714
+ const r = outcome.route;
2715
+ if (r) {
2716
+ lines.push(
2717
+ r.status === "navigated" ? ` \u2713 route \u2014 navigated to ${r.href}` : r.status === "deferred" ? ` \u21BB route \u2014 ${r.href} will be opened after the reload` : ` \u2715 route \u2014 ${r.error ?? r.status}`
2718
+ );
2719
+ }
2435
2720
  return `Restored "${outcome.snapshotName}" (${outcome.mode}${outcome.willReload ? ", app reloading" : ""}):
2436
2721
  ${lines.join("\n")}`;
2437
2722
  }
@@ -2439,7 +2724,7 @@ function registerTimeMachineTools(server, sync) {
2439
2724
  server.registerTool(
2440
2725
  "get_time_machine",
2441
2726
  {
2442
- description: "List the app's Time Machine state snapshots (restore points) and which state sources (storage/redux/zustand/jotai/react-query) can currently be captured/restored. Use time_machine_action to capture a snapshot before driving a flow, then restore it to re-run the flow from the same state.",
2727
+ description: "List the app's Time Machine state snapshots (restore points) and which state sources (storage/redux/zustand/jotai/react-query) can currently be captured/restored. Each snapshot also records the route it was captured on (shown as `route`), which a restore can navigate back to. Use time_machine_action to capture a snapshot before driving a flow, then restore it to re-run the flow from the same state.",
2443
2728
  inputSchema: {
2444
2729
  deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2445
2730
  }
@@ -2452,13 +2737,19 @@ function registerTimeMachineTools(server, sync) {
2452
2737
  const snapshots = p.snapshots ?? [];
2453
2738
  const providers = p.providers ?? [];
2454
2739
  const providerLines = providers.map((pr) => {
2455
- const cap = pr.canCapture ? pr.canRestore ? "capture+restore" : "capture only" : "unavailable";
2456
- return `- ${pr.label} (${pr.id}): ${cap}${pr.detail ? ` \u2014 ${pr.detail}` : ""}`;
2740
+ const cap3 = pr.canCapture ? pr.canRestore ? "capture+restore" : "capture only" : "unavailable";
2741
+ return `- ${pr.label} (${pr.id}): ${cap3}${pr.detail ? ` \u2014 ${pr.detail}` : ""}`;
2457
2742
  });
2743
+ if (p.route) {
2744
+ providerLines.push(
2745
+ p.route.available ? `- Route (route): capture+restore \u2014 recorded with every snapshot; pass restoreRoute:true to return to it${p.route.current ? ` (now ${p.route.current})` : ""}` : "- Route (route): unavailable \u2014 no Expo Router on this device, snapshots record no route"
2746
+ );
2747
+ }
2458
2748
  const snapLines = snapshots.map((s) => {
2459
2749
  const warnings = Object.values(s.sources).reduce((n, src) => n + src.warnings.length, 0);
2460
2750
  const desc = s.baseline ? "baseline \u2014 restore wipes to fresh install (always reloads)" : `${kb(s.sizeBytes)} \xB7 ${Object.keys(s.sources).join(", ")}`;
2461
- return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
2751
+ const route = s.route ? ` \xB7 route ${s.route.href}` : "";
2752
+ return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${route}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
2462
2753
  });
2463
2754
  const parts = [
2464
2755
  `**Time Machine \u2014 ${snapshots.length} snapshot(s):**`,
@@ -2477,13 +2768,14 @@ ${formatOutcome(p.lastOutcome)}`);
2477
2768
  server.registerTool(
2478
2769
  "time_machine_action",
2479
2770
  {
2480
- description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2771
+ description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload; pass restoreRoute:true to also return to the screen the snapshot was captured on), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2481
2772
  inputSchema: {
2482
2773
  action: import_zod12.z.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
2483
2774
  id: import_zod12.z.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
2484
2775
  name: import_zod12.z.string().optional().describe("Snapshot name (for capture and rename)."),
2485
2776
  mode: import_zod12.z.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
2486
2777
  excludeKeys: import_zod12.z.array(import_zod12.z.string()).optional().describe('Restore only: item keys to LEAVE UNTOUCHED, as "sourceId::itemKey" (itemKey = the key shown by the preview action, e.g. "query::[\\"pokemon\\"]" or "storage::async:@cart").'),
2778
+ restoreRoute: import_zod12.z.boolean().optional().describe("Restore only: also navigate back to the route the snapshot was captured on (shown as `route` by get_time_machine). With mode 'reload' the navigation happens automatically on the next boot. Default false \u2014 state is restored but the app stays on the current screen."),
2487
2779
  sourceIds: import_zod12.z.array(import_zod12.z.string()).optional().describe("Capture/restore only these source ids (default all)."),
2488
2780
  deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2489
2781
  }
@@ -2532,7 +2824,8 @@ ${lines.join("\n")}`);
2532
2824
  const outcome = await call("restore", {
2533
2825
  id: args.id,
2534
2826
  mode: args.mode ?? "live",
2535
- excludeKeys: args.excludeKeys
2827
+ excludeKeys: args.excludeKeys,
2828
+ restoreRoute: args.restoreRoute
2536
2829
  });
2537
2830
  return text11(formatOutcome(outcome));
2538
2831
  }
@@ -2750,11 +3043,14 @@ function formatElement(e) {
2750
3043
  if (e.nativeTag != null) line += ` #${e.nativeTag}`;
2751
3044
  return line;
2752
3045
  }
3046
+ var tapCount = 0;
3047
+ var FLOW_TIP_EVERY = 3;
3048
+ var FLOW_TIP = "\n\nTip: driving several steps? `run_flow` does a whole tap/type/wait/assert sequence in ONE call and reports the final screen plus the network and console output it produced.";
2753
3049
  function registerInteractTools(server, sync) {
2754
3050
  server.registerTool(
2755
3051
  "describe_screen",
2756
3052
  {
2757
- description: "List the interactive / labeled elements currently on screen \u2014 the app's live accessibility-style tree walked from the React fiber tree. Each row shows the component name, visible text/label, role, testID, a normalized tap point (tap: x,y in 0\u20131), and its #nativeTag. Rows marked with \u2022 are tappable (have an onPress). Use this to see what's on screen and pick something to tap with tap_element \u2014 no screenshot needed. Re-run after navigating or when the screen changes, since positions move. Requires Buoy Pro.",
3053
+ description: "List the interactive / labeled elements currently on screen \u2014 the app's live accessibility-style tree walked from the React fiber tree. Each row shows the component name, visible text/label, role, testID, a normalized tap point (tap: x,y in 0\u20131), and its #nativeTag. Rows marked with \u2022 are tappable (have an onPress). Use this to see what's on screen and pick something to tap with tap_element \u2014 no screenshot needed. If you already know the sequence you want to drive, `run_flow` does the whole thing (taps, waits, assertions) in one call instead. Re-run after navigating or when the screen changes, since positions move. Requires Buoy Pro.",
2758
3054
  inputSchema: {
2759
3055
  interactiveOnly: import_zod14.z.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
2760
3056
  deviceId: import_zod14.z.string().optional().describe("Target device id (auto-selected if one is connected).")
@@ -2783,7 +3079,7 @@ ${elements.map(formatElement).join("\n")}`);
2783
3079
  server.registerTool(
2784
3080
  "tap_element",
2785
3081
  {
2786
- description: "Interact with an on-screen element by invoking its handler directly in JS (no screenshot, no coordinate math, works on real devices). Handles buttons (onPress + onLongPress), toggles/switches and sliders (onValueChange), and text inputs (onChangeText) \u2014 see each element's `control` in describe_screen. Identify the target with an exact `nativeTag` or `testID`, or a fuzzy `query`. Pass `longPress:true` to invoke onLongPress instead of onPress (elements that support it show {long-press} in describe_screen). For a toggle, pass `value:true/false` (omit to flip); for a slider, pass a numeric `value` (omit to jump to the far end); for a text input, pass `text`. If the element is off-screen it's scrolled into view first (scrollIntoView:false to skip). Call describe_screen first to see options + current values. Requires Buoy Pro.",
3082
+ description: "Interact with an on-screen element by invoking its handler directly in JS (no screenshot, no coordinate math, works on real devices). Handles buttons (onPress + onLongPress), toggles/switches and sliders (onValueChange), and text inputs (onChangeText) \u2014 see each element's `control` in describe_screen. Identify the target with an exact `nativeTag` or `testID`, or a fuzzy `query`. Pass `longPress:true` to invoke onLongPress instead of onPress (elements that support it show {long-press} in describe_screen). For a toggle, pass `value:true/false` (omit to flip); for a slider, pass a numeric `value` (omit to jump to the far end); for a text input, pass `text`. If the element is off-screen it's scrolled into view first (scrollIntoView:false to skip). Call describe_screen first to see options + current values. Driving MORE THAN ONE step (reproducing a bug, verifying a fix)? Use `run_flow` instead \u2014 it runs the whole sequence in one call. Requires Buoy Pro.",
2787
3083
  inputSchema: {
2788
3084
  nativeTag: import_zod14.z.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
2789
3085
  testID: import_zod14.z.string().optional().describe("Exact testID of the element."),
@@ -2815,13 +3111,15 @@ ${elements.map(formatElement).join("\n")}`);
2815
3111
  },
2816
3112
  target
2817
3113
  );
3114
+ tapCount += 1;
3115
+ const flowTip = tapCount >= FLOW_TIP_EVERY && tapCount % FLOW_TIP_EVERY === 0 ? FLOW_TIP : "";
2818
3116
  if (res?.tapped && res.matched) {
2819
3117
  const m = res.matched;
2820
3118
  const label = m.text ?? m.label ?? m.name;
2821
3119
  const id = m.testID ? ` [testID=${m.testID}]` : m.nativeTag != null ? ` #${m.nativeTag}` : "";
2822
3120
  const scrolledNote = res.scrolled ? " (scrolled into view)" : "";
2823
3121
  const verb = m.via === "onValueChange" ? `Set "${label}"${id} \u2192 ${JSON.stringify(m.value)}` : m.via === "onChangeText" ? `Typed ${JSON.stringify(m.value)} into "${label}"${id}` : m.via === "onLongPress" ? `Long-pressed "${label}"${id}` : `Tapped "${label}"${id}`;
2824
- return text12(`${verb}${scrolledNote}.`);
3122
+ return text12(`${verb}${scrolledNote}.${flowTip}`);
2825
3123
  }
2826
3124
  let msg = res?.reason ?? "Tap failed.";
2827
3125
  if (res?.candidates?.length) {
@@ -2909,7 +3207,7 @@ function registerJsThreadTools(server, sync) {
2909
3207
  server.registerTool(
2910
3208
  "get_js_thread_top",
2911
3209
  {
2912
- 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.",
3210
+ 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. NOT the tool for React components re-rendering too often \u2014 this measures TASKS on the thread, not renders; for "the page re-renders constantly / typing lags / tapping one button re-renders everything" use measure_renders. Requires @buoy-gg/js-top on the device.',
2913
3211
  inputSchema: {
2914
3212
  deviceId: import_zod15.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
2915
3213
  durationMs: import_zod15.z.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
@@ -3489,6 +3787,62 @@ ${JSON.stringify(slim)}
3489
3787
 
3490
3788
  // src/tools/packs/reload.ts
3491
3789
  var import_zod18 = require("zod");
3790
+
3791
+ // src/capture/devServerReload.ts
3792
+ var import_ws = require("ws");
3793
+ var CONNECT_TIMEOUT_MS = 3e3;
3794
+ function messageSocketUrl(origin) {
3795
+ return `${origin.replace(/^http/, "ws").replace(/\/+$/, "")}/message`;
3796
+ }
3797
+ function reloadViaDevServer(origin) {
3798
+ return new Promise((resolve) => {
3799
+ let settled = false;
3800
+ const done = (result) => {
3801
+ if (settled) return;
3802
+ settled = true;
3803
+ resolve(result);
3804
+ };
3805
+ let socket;
3806
+ try {
3807
+ socket = new import_ws.WebSocket(messageSocketUrl(origin));
3808
+ } catch (e) {
3809
+ done({ ok: false, error: e.message });
3810
+ return;
3811
+ }
3812
+ const timer = setTimeout(() => {
3813
+ try {
3814
+ socket.close();
3815
+ } catch {
3816
+ }
3817
+ done({ ok: false, error: `dev server at ${origin} did not accept a connection` });
3818
+ }, CONNECT_TIMEOUT_MS);
3819
+ let sent = false;
3820
+ socket.on("open", () => {
3821
+ try {
3822
+ socket.send(JSON.stringify({ version: 2, method: "reload" }));
3823
+ sent = true;
3824
+ clearTimeout(timer);
3825
+ setTimeout(() => {
3826
+ try {
3827
+ socket.close();
3828
+ } catch {
3829
+ }
3830
+ done({ ok: true });
3831
+ }, 250);
3832
+ } catch (e) {
3833
+ clearTimeout(timer);
3834
+ done({ ok: false, error: e.message });
3835
+ }
3836
+ });
3837
+ socket.on("error", (e) => {
3838
+ clearTimeout(timer);
3839
+ if (sent) return;
3840
+ done({ ok: false, error: e.message });
3841
+ });
3842
+ });
3843
+ }
3844
+
3845
+ // src/tools/packs/reload.ts
3492
3846
  var APP_TOOL_ID = "app";
3493
3847
  var RELOAD_ACTION = "reloadApp";
3494
3848
  var DEFAULT_WAIT_MS = 3e4;
@@ -3501,7 +3855,11 @@ var errMsg18 = (e) => e instanceof Error ? e.message : String(e);
3501
3855
  var delay3 = (ms) => new Promise((r) => setTimeout(r, ms));
3502
3856
  async function ping(sync, deviceId, timeoutMs) {
3503
3857
  try {
3504
- const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, { timeoutMs });
3858
+ const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, {
3859
+ timeoutMs,
3860
+ silent: true
3861
+ });
3862
+ sync.noteDevServerOrigin(deviceId, pong?.devServerOrigin);
3505
3863
  return typeof pong?.startedAt === "number" ? pong.startedAt : null;
3506
3864
  } catch {
3507
3865
  return null;
@@ -3530,7 +3888,7 @@ function registerReloadTools(server, sync) {
3530
3888
  server.registerTool(
3531
3889
  "reload_app",
3532
3890
  {
3533
- description: "Reload the running app's JS bundle (Expo Go, dev client, or RN CLI) \u2014 the equivalent of shaking the device and hitting Reload, but from here. Use it to pick up a change Fast Refresh didn't apply, to clear leaked in-memory state before a measurement, or to re-run app startup. In dev builds it uses React Native's DevSettings.reload(); otherwise it falls back to expo-updates (if the app installs it). All in-memory state is lost. By default this waits for the app to come back up and reports how long the reload took. Works on any app running @buoy-gg/core \u2014 no particular tool package needed. Requires Buoy Pro.",
3891
+ description: "Reload the running app's JS bundle (Expo Go, dev client, or RN CLI) \u2014 the equivalent of shaking the device and hitting Reload, but from here. Use it to pick up a change Fast Refresh didn't apply, to clear leaked in-memory state before a measurement, to re-run app startup, or to RECOVER A CRASHED APP: after a fatal error the other tools stop answering, and reloading is how you get them back (it falls through to the dev server when the app is too broken to reload itself). In dev builds it uses React Native's DevSettings.reload(); otherwise it falls back to expo-updates (if the app installs it). All in-memory state is lost \u2014 read anything you need from get_console/get_storage BEFORE reloading. By default this waits for the app to come back up and reports how long the reload took. Works on any app running @buoy-gg/core \u2014 no particular tool package needed. Requires Buoy Pro.",
3534
3892
  inputSchema: {
3535
3893
  strategy: import_zod18.z.enum(["auto", "dev-settings", "expo-updates"]).optional().describe(
3536
3894
  "Reload mechanism. 'auto' (default) uses DevSettings in dev builds and expo-updates otherwise. Force one only when debugging the reload itself."
@@ -3545,13 +3903,27 @@ function registerReloadTools(server, sync) {
3545
3903
  const target = sync.requireProDevice(args.deviceId);
3546
3904
  assertReloadSupported(sync, target);
3547
3905
  const previousRealm = args.wait === false ? null : await ping(sync, target, PROBE_TIMEOUT_MS);
3548
- const ack = await sync.callAction(
3549
- APP_TOOL_ID,
3550
- RELOAD_ACTION,
3551
- { strategy: args.strategy ?? "auto" },
3552
- target
3553
- );
3554
- const via = ack?.strategy ? ` via ${ack.strategy}` : "";
3906
+ let ack = null;
3907
+ let recovered = false;
3908
+ try {
3909
+ ack = await sync.callAction(
3910
+ APP_TOOL_ID,
3911
+ RELOAD_ACTION,
3912
+ { strategy: args.strategy ?? "auto" },
3913
+ target
3914
+ );
3915
+ } catch (inAppError) {
3916
+ const origin = sync.devServerOrigin(target);
3917
+ if (!origin) throw inAppError;
3918
+ const res = await reloadViaDevServer(origin);
3919
+ if (!res.ok) {
3920
+ throw new Error(
3921
+ `The app is not answering (it looks crashed) and the dev server at ${origin} could not be reached either: ${res.error}. Reload the app by hand.`
3922
+ );
3923
+ }
3924
+ recovered = true;
3925
+ }
3926
+ const via = recovered ? ` via the dev server (the app was unresponsive \u2014 its own reload could not be reached)` : ack?.strategy ? ` via ${ack.strategy}` : "";
3555
3927
  if (args.wait === false) {
3556
3928
  return text16(`Reload requested${via}. The app is restarting \u2014 its in-memory state is gone; re-read anything you cached.`);
3557
3929
  }
@@ -3571,12 +3943,836 @@ function registerReloadTools(server, sync) {
3571
3943
  );
3572
3944
  }
3573
3945
 
3946
+ // src/tools/packs/triage.ts
3947
+ var import_zod19 = require("zod");
3948
+ var text17 = (s) => ({ content: [{ type: "text", text: s }] });
3949
+ var fail19 = (message) => ({
3950
+ isError: true,
3951
+ content: [{ type: "text", text: message }]
3952
+ });
3953
+ var errMsg19 = (e) => e instanceof Error ? e.message : String(e);
3954
+ var PROBE_TIMEOUT_MS2 = 5e3;
3955
+ var MAX_STORAGE_KEYS = 20;
3956
+ var MAX_STORAGE_VALUE = 400;
3957
+ var STORAGE_VALUE_BUDGET = 1600;
3958
+ var MAX_LINE = 160;
3959
+ var cap = (s, n = MAX_LINE) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
3960
+ function hhmmss2(ts) {
3961
+ if (!ts) return "--:--:--";
3962
+ const d = new Date(ts);
3963
+ const p = (n) => String(n).padStart(2, "0");
3964
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
3965
+ }
3966
+ async function otherBackends(sync, target, opts) {
3967
+ const notes = [];
3968
+ try {
3969
+ const mmkv = await sync.callAction("storage", "mmkv.snapshot", {}, target, opts);
3970
+ for (const inst of mmkv ?? []) {
3971
+ const n = inst.entries?.length ?? 0;
3972
+ if (n > 0) notes.push(`MMKV "${inst.id}": ${n} key${n === 1 ? "" : "s"}`);
3973
+ }
3974
+ } catch {
3975
+ }
3976
+ try {
3977
+ const secure = await sync.callAction("storage", "secure.keys", {}, target, opts);
3978
+ if (secure?.length) notes.push(`SecureStore: ${secure.length} key${secure.length === 1 ? "" : "s"}`);
3979
+ } catch {
3980
+ }
3981
+ return notes.length ? `
3982
+ ${notes.join(" \xB7 ")} \u2014 read with get_storage` : "";
3983
+ }
3984
+ async function section(title, run) {
3985
+ try {
3986
+ const body = await run();
3987
+ return body ? `${title}
3988
+ ${body}` : null;
3989
+ } catch (e) {
3990
+ const msg = errMsg19(e);
3991
+ if (/not responding|timed out|got no answer/i.test(msg)) {
3992
+ return `${title}
3993
+ (no answer \u2014 see the note above)`;
3994
+ }
3995
+ return `${title}
3996
+ (unavailable: ${cap(msg, 120)})`;
3997
+ }
3998
+ }
3999
+ function registerTriageTools(server, sync) {
4000
+ server.registerTool(
4001
+ "get_triage",
4002
+ {
4003
+ description: "START HERE when debugging a running app. One call = the app's current runtime state: connected device, recent console errors (including [FATAL] crashes), recent network requests with statuses, persisted storage values, and what's on screen right now. Use it as the first step for ANY 'why is the app doing X' question \u2014 it is far cheaper than calling get_console + get_network_requests + get_storage + describe_screen separately, and it tells you which of those to drill into next. If the app has crashed it says so and shows the crash. Requires Buoy Pro.",
4004
+ inputSchema: {
4005
+ deviceId: import_zod19.z.string().optional().describe("Target device id (auto-selected if one is connected).")
4006
+ }
4007
+ },
4008
+ async (args) => {
4009
+ try {
4010
+ const target = sync.requireProDevice(args.deviceId);
4011
+ const device = sync.connectedDevices().find((d) => d.deviceId === target);
4012
+ const toolIds = new Set((device?.tools ?? []).map((t) => t.toolId));
4013
+ const head = `**${device?.deviceName ?? target}** (${device?.platform ?? "?"}, id \`${target}\`) \u2014 tools: ${[...toolIds].join(", ") || "none"}`;
4014
+ const opts = { timeoutMs: PROBE_TIMEOUT_MS2 };
4015
+ const [consoleSec, netSec, storeSec, screenSec] = await Promise.all([
4016
+ section("**Console \u2014 recent errors/warnings:**", async () => {
4017
+ if (!toolIds.has("console")) return null;
4018
+ const { payload } = await sync.getSnapshot("console", target, opts);
4019
+ const p = payload;
4020
+ const entries = Array.isArray(p) ? p : p?.entries ?? [];
4021
+ const bad = entries.filter(
4022
+ (e) => e.level === "error" || e.level === "warning"
4023
+ );
4024
+ const shown = bad.slice(-8);
4025
+ if (shown.length === 0) {
4026
+ return `(none \u2014 ${entries.length} entries captured)`;
4027
+ }
4028
+ return shown.map(
4029
+ (e) => `${hhmmss2(e.timestamp)} [${e.level === "error" ? "ERR" : "warn"}] ${cap(
4030
+ (e.message ?? "").split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 3).join(" \u23CE "),
4031
+ 220
4032
+ )}`
4033
+ ).join("\n");
4034
+ }),
4035
+ section("**Network \u2014 recent requests:**", async () => {
4036
+ if (!toolIds.has("network")) return null;
4037
+ const { payload } = await sync.getSnapshot("network", target, opts);
4038
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
4039
+ if (events.length === 0) return "(no requests captured yet)";
4040
+ const shown = events.slice(-8);
4041
+ return shown.map((e) => {
4042
+ const mark = e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705";
4043
+ const where = e.path ?? e.url ?? "?";
4044
+ const status = e.error ?? e.status ?? "pending";
4045
+ return `${mark} ${e.method ?? "GET"} ${cap(String(where), 80)} \u2192 ${status}${e.duration ? ` (${Math.round(e.duration)}ms)` : ""}`;
4046
+ }).join("\n");
4047
+ }),
4048
+ section("**Storage \u2014 persisted values:**", async () => {
4049
+ if (!toolIds.has("storage")) return null;
4050
+ const keys = await sync.callAction(
4051
+ "storage",
4052
+ "async.getAllKeys",
4053
+ {},
4054
+ target,
4055
+ opts
4056
+ );
4057
+ if (!Array.isArray(keys) || keys.length === 0) {
4058
+ return "(no app keys)";
4059
+ }
4060
+ const head2 = keys.slice(0, MAX_STORAGE_KEYS);
4061
+ let pairs = [];
4062
+ try {
4063
+ pairs = await sync.callAction(
4064
+ "storage",
4065
+ "async.multiGet",
4066
+ { keys: head2 },
4067
+ target,
4068
+ opts
4069
+ ) ?? [];
4070
+ } catch {
4071
+ return `${head2.map((k) => `\`${k}\``).join(", ")}
4072
+ _Values: get_storage includeValues:true_`;
4073
+ }
4074
+ const byKey = new Map(pairs);
4075
+ let budget = STORAGE_VALUE_BUDGET;
4076
+ const lines = head2.map((k) => {
4077
+ const v = byKey.get(k);
4078
+ if (v == null) return `- \`${k}\` = (null)`;
4079
+ if (v.length > MAX_STORAGE_VALUE) {
4080
+ return `- \`${k}\` = (${(v.length / 1024).toFixed(1)} KB \u2014 read with get_storage)`;
4081
+ }
4082
+ if (budget <= 0) return `- \`${k}\` = (not shown \u2014 triage budget spent; read with get_storage)`;
4083
+ budget -= v.length;
4084
+ return `- \`${k}\` = ${v}`;
4085
+ });
4086
+ const more = keys.length - head2.length;
4087
+ const others = await otherBackends(sync, target, opts);
4088
+ return `${lines.join("\n")}${more > 0 ? `
4089
+ \u2026+${more} more keys` : ""}${others}`;
4090
+ }),
4091
+ section("**Screen \u2014 what's visible now:**", async () => {
4092
+ if (!toolIds.has("highlight-updates")) return null;
4093
+ const res = await sync.callAction(
4094
+ "highlight-updates",
4095
+ "describeScreen",
4096
+ {},
4097
+ target,
4098
+ opts
4099
+ );
4100
+ const els = (res?.elements ?? []).filter((e) => e.interactive);
4101
+ if (els.length === 0) return "(nothing interactive on screen)";
4102
+ return els.slice(0, 20).map(
4103
+ (e) => `\u2022 ${e.name}${e.text || e.label ? ` "${cap(e.text ?? e.label ?? "", 60)}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`
4104
+ ).join("\n");
4105
+ })
4106
+ ]);
4107
+ const crashed = sync.isUnresponsive(target);
4108
+ const banner = crashed ? `
4109
+
4110
+ \u{1F6D1} ${sync.unresponsiveNote(target)}` : "";
4111
+ const body = [consoleSec, netSec, storeSec, screenSec].filter(Boolean).join("\n\n");
4112
+ return text17(
4113
+ `${head}${banner}
4114
+
4115
+ ${body}
4116
+
4117
+ _Drill in with get_console, get_network_requests (includeBodies:true for response bodies), get_storage (includeValues:true), describe_screen._`
4118
+ );
4119
+ } catch (e) {
4120
+ return fail19(errMsg19(e));
4121
+ }
4122
+ }
4123
+ );
4124
+ }
4125
+
4126
+ // src/tools/packs/flow.ts
4127
+ var import_zod20 = require("zod");
4128
+ var UI_TOOL_ID = "highlight-updates";
4129
+ var APP_TOOL_ID2 = "app";
4130
+ var text18 = (s) => ({ content: [{ type: "text", text: s }] });
4131
+ var fail20 = (message) => ({
4132
+ isError: true,
4133
+ content: [{ type: "text", text: message }]
4134
+ });
4135
+ var errMsg20 = (e) => e instanceof Error ? e.message : String(e);
4136
+ var delay4 = (ms) => new Promise((r) => setTimeout(r, ms));
4137
+ var POLL_MS = 250;
4138
+ var DEFAULT_WAIT_MS2 = 5e3;
4139
+ var ASSERT_SETTLE_MS = 2e3;
4140
+ var TEARDOWN_GRACE_MS2 = 1200;
4141
+ var REALM_PROBE_MS = 1500;
4142
+ var MAX_FLOW_MS = 12e4;
4143
+ function matches(e, needle) {
4144
+ if (e.testID && e.testID === needle) return true;
4145
+ const n = needle.toLowerCase();
4146
+ return [e.testID, e.text, e.label, e.name].filter(Boolean).some((v) => String(v).toLowerCase().includes(n));
4147
+ }
4148
+ function formatElement2(e) {
4149
+ const display = e.text ?? e.label;
4150
+ return `${e.interactive ? "\u2022 " : " "}${e.name}${display ? ` "${display}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`;
4151
+ }
4152
+ var stepSchema = import_zod20.z.object({
4153
+ tap: import_zod20.z.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
4154
+ longPress: import_zod20.z.boolean().optional().describe("Make `tap` a long-press instead."),
4155
+ type: import_zod20.z.string().optional().describe("Text to type (needs `into`)."),
4156
+ into: import_zod20.z.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
4157
+ set: import_zod20.z.union([import_zod20.z.number(), import_zod20.z.boolean()]).optional().describe("Set a toggle (true/false) or slider (number) \u2014 pair with `tap` as the target."),
4158
+ reload: import_zod20.z.boolean().optional().describe("Reload the JS bundle before this step's action."),
4159
+ waitMs: import_zod20.z.number().min(0).max(3e4).optional().describe("Fixed pause after the action, in ms (max 30000)."),
4160
+ waitFor: import_zod20.z.string().optional().describe("Wait until an element matching this appears (default 5s)."),
4161
+ waitGone: import_zod20.z.string().optional().describe("Wait until no element matches this (default 5s)."),
4162
+ expect: import_zod20.z.string().optional().describe("Assert an element matching this IS on screen (waits up to 2s for it); fails the flow if not."),
4163
+ expectGone: import_zod20.z.string().optional().describe("Assert nothing matches this (waits up to 2s for it to go); fails the flow if something does."),
4164
+ timeoutMs: import_zod20.z.number().min(0).max(6e4).optional().describe("Budget for this step's waitFor/waitGone/expect, max 60000. Default 5000 (2000 for expect).")
4165
+ }).describe("One flow step. Order within a step: reload \u2192 tap/type/set \u2192 waitMs \u2192 waitFor/waitGone \u2192 expect.");
4166
+ function registerFlowTools(server, sync) {
4167
+ server.registerTool(
4168
+ "run_flow",
4169
+ {
4170
+ description: "Run a scripted sequence of UI interactions in ONE call and report what happened: per-step results, the final screen, plus the network requests and console errors the flow produced. Use this instead of alternating describe_screen/tap_element \u2014 reproducing a bug ('open cart, checkout, submit') or verifying a fix after an edit is one call, not a dozen. Steps run in order and stop at the first failure, returning the screen at that point so you can see WHERE it broke. Each step can tap, type, set a toggle/slider, reload, wait for an element, and assert. Requires Buoy Pro.",
4171
+ inputSchema: {
4172
+ steps: import_zod20.z.array(stepSchema).min(1).describe("Steps to run, in order."),
4173
+ include: import_zod20.z.enum(["all", "screen", "none"]).optional().describe("What to report after the flow: 'all' (default) = final screen + network/console produced by the flow; 'screen' = final screen only; 'none' = step results only."),
4174
+ continueOnFailure: import_zod20.z.boolean().optional().describe("Keep going after a failed step instead of stopping. Default false."),
4175
+ deviceId: import_zod20.z.string().optional().describe("Target device id (auto-selected if one is connected).")
4176
+ }
4177
+ },
4178
+ async (args) => {
4179
+ const started = Date.now();
4180
+ try {
4181
+ const target = sync.requireProDevice(args.deviceId);
4182
+ const include = args.include ?? "all";
4183
+ const describe = async () => {
4184
+ const res = await sync.callAction(UI_TOOL_ID, "describeScreen", {}, target);
4185
+ return res?.elements ?? [];
4186
+ };
4187
+ const realmId = async () => {
4188
+ try {
4189
+ const pong = await sync.callAction(APP_TOOL_ID2, "ping", {}, target, {
4190
+ timeoutMs: REALM_PROBE_MS,
4191
+ silent: true
4192
+ // probes across a reload are expected to fail
4193
+ });
4194
+ return typeof pong?.startedAt === "number" ? pong.startedAt : null;
4195
+ } catch {
4196
+ return null;
4197
+ }
4198
+ };
4199
+ const untilNewRealm = async (previous, budgetMs) => {
4200
+ const t0 = Date.now();
4201
+ while (Date.now() - t0 < budgetMs) {
4202
+ const realm = await realmId();
4203
+ if (realm !== null && realm !== previous) return Date.now() - t0;
4204
+ await delay4(POLL_MS);
4205
+ }
4206
+ return null;
4207
+ };
4208
+ const beforeConsole = /* @__PURE__ */ new Set();
4209
+ const beforeNetwork = /* @__PURE__ */ new Set();
4210
+ const toolIds = new Set(
4211
+ (sync.connectedDevices().find((d) => d.deviceId === target)?.tools ?? []).map((t) => t.toolId)
4212
+ );
4213
+ if (include === "all") {
4214
+ if (toolIds.has("console")) {
4215
+ const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
4216
+ const entries = payload?.entries ?? [];
4217
+ for (const e of entries) beforeConsole.add(e.id);
4218
+ }
4219
+ if (toolIds.has("network")) {
4220
+ const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
4221
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
4222
+ for (const e of events) beforeNetwork.add(e.id);
4223
+ }
4224
+ }
4225
+ const until = async (predicate, budgetMs) => {
4226
+ const t0 = Date.now();
4227
+ for (; ; ) {
4228
+ if (Date.now() - t0 >= budgetMs || Date.now() - started > MAX_FLOW_MS) {
4229
+ return { ok: false, ms: Date.now() - t0 };
4230
+ }
4231
+ let els = [];
4232
+ try {
4233
+ els = await describe();
4234
+ } catch {
4235
+ }
4236
+ if (predicate(els)) return { ok: true, ms: Date.now() - t0 };
4237
+ await delay4(POLL_MS);
4238
+ }
4239
+ };
4240
+ const lines = [];
4241
+ let failedAt = null;
4242
+ let okCount = 0;
4243
+ for (let i = 0; i < args.steps.length; i++) {
4244
+ const step = args.steps[i];
4245
+ const label = `${i + 1}`;
4246
+ const budget = step.timeoutMs ?? DEFAULT_WAIT_MS2;
4247
+ if (Date.now() - started > MAX_FLOW_MS) {
4248
+ lines.push(`${label} \u2717 flow exceeded ${MAX_FLOW_MS / 1e3}s \u2014 stopped here`);
4249
+ failedAt = i;
4250
+ break;
4251
+ }
4252
+ const stepErrors = [];
4253
+ const done = [];
4254
+ const actionable = step.reload || step.tap || step.type !== void 0 || step.waitMs || step.waitFor || step.waitGone || step.expect || step.expectGone;
4255
+ if (!actionable) {
4256
+ lines.push(`${i + 1} \u2717 step does nothing \u2014 give it a tap/type/wait/expect${step.set !== void 0 ? " (`set` needs `tap` naming the control)" : ""}`);
4257
+ failedAt = failedAt ?? i;
4258
+ if (!args.continueOnFailure) break;
4259
+ continue;
4260
+ }
4261
+ try {
4262
+ if (step.reload) {
4263
+ const previousRealm = await realmId();
4264
+ await sync.callAction(APP_TOOL_ID2, "reloadApp", { strategy: "auto" }, target);
4265
+ await delay4(TEARDOWN_GRACE_MS2);
4266
+ const back = await untilNewRealm(previousRealm, Math.max(budget, 2e4));
4267
+ if (back == null) {
4268
+ stepErrors.push("app did not come back after reload");
4269
+ } else {
4270
+ done.push(`reloaded (${back}ms)`);
4271
+ await until((els) => els.length > 0, 1e4);
4272
+ }
4273
+ }
4274
+ if (step.type !== void 0) {
4275
+ const targetSel = step.into ?? step.tap;
4276
+ if (!targetSel) {
4277
+ stepErrors.push("`type` needs `into` (which input to type into)");
4278
+ } else {
4279
+ const res = await sync.callAction(
4280
+ UI_TOOL_ID,
4281
+ "tapElement",
4282
+ { ...selector(targetSel), text: step.type },
4283
+ target
4284
+ );
4285
+ if (!res?.tapped) {
4286
+ stepErrors.push(res?.reason ?? `could not type into "${targetSel}"`);
4287
+ } else if (res.matched?.via && res.matched.via !== "onChangeText") {
4288
+ stepErrors.push(
4289
+ `"${targetSel}" is not a text input \u2014 it ran ${res.matched.via} instead, and the text was not entered`
4290
+ );
4291
+ } else {
4292
+ done.push(`typed ${JSON.stringify(step.type)} into "${targetSel}"`);
4293
+ }
4294
+ }
4295
+ } else if (step.tap) {
4296
+ const params = { ...selector(step.tap) };
4297
+ if (step.longPress) params.longPress = true;
4298
+ if (step.set !== void 0) params.value = step.set;
4299
+ const res = await sync.callAction(UI_TOOL_ID, "tapElement", params, target);
4300
+ if (!res?.tapped) {
4301
+ stepErrors.push(res?.reason ?? `no element matched "${step.tap}"`);
4302
+ } else {
4303
+ const m = res.matched;
4304
+ const what = m?.text ?? m?.label ?? m?.name ?? step.tap;
4305
+ if (step.set !== void 0 && m?.via && m.via !== "onValueChange") {
4306
+ stepErrors.push(
4307
+ `"${what}" is not a toggle/slider \u2014 it ran ${m.via} instead, so it was not set to ${JSON.stringify(step.set)}`
4308
+ );
4309
+ } else {
4310
+ done.push(
4311
+ step.set !== void 0 ? `set "${what}" \u2192 ${JSON.stringify(m?.value ?? step.set)}` : `${step.longPress ? "long-pressed" : "tapped"} "${what}"`
4312
+ );
4313
+ }
4314
+ }
4315
+ }
4316
+ if (!stepErrors.length && step.waitMs) {
4317
+ await delay4(step.waitMs);
4318
+ done.push(`waited ${step.waitMs}ms`);
4319
+ }
4320
+ if (!stepErrors.length && step.waitFor) {
4321
+ const r = await until((els) => els.some((e) => matches(e, step.waitFor)), budget);
4322
+ if (r.ok) done.push(`"${step.waitFor}" appeared (${r.ms}ms)`);
4323
+ else stepErrors.push(`"${step.waitFor}" never appeared within ${budget}ms`);
4324
+ }
4325
+ if (!stepErrors.length && step.waitGone) {
4326
+ const r = await until((els) => !els.some((e) => matches(e, step.waitGone)), budget);
4327
+ if (r.ok) done.push(`"${step.waitGone}" gone (${r.ms}ms)`);
4328
+ else stepErrors.push(`"${step.waitGone}" was still there after ${budget}ms`);
4329
+ }
4330
+ if (!stepErrors.length && step.expect) {
4331
+ const r = await until(
4332
+ (els) => els.some((e) => matches(e, step.expect)),
4333
+ step.timeoutMs ?? ASSERT_SETTLE_MS
4334
+ );
4335
+ if (r.ok) done.push(`"${step.expect}" is on screen`);
4336
+ else stepErrors.push(`expected "${step.expect}" on screen \u2014 not found`);
4337
+ }
4338
+ if (!stepErrors.length && step.expectGone) {
4339
+ const r = await until(
4340
+ (els) => !els.some((e) => matches(e, step.expectGone)),
4341
+ step.timeoutMs ?? ASSERT_SETTLE_MS
4342
+ );
4343
+ if (r.ok) done.push(`"${step.expectGone}" is gone`);
4344
+ else stepErrors.push(`expected "${step.expectGone}" to be gone \u2014 still on screen`);
4345
+ }
4346
+ } catch (e) {
4347
+ stepErrors.push(errMsg20(e));
4348
+ }
4349
+ if (stepErrors.length) {
4350
+ lines.push(`${label} \u2717 ${describeStep(step)} \u2014 ${stepErrors.join("; ")}`);
4351
+ failedAt = failedAt ?? i;
4352
+ if (!args.continueOnFailure) break;
4353
+ } else {
4354
+ okCount++;
4355
+ lines.push(`${label} \u2713 ${done.join(", ") || describeStep(step)}`);
4356
+ }
4357
+ }
4358
+ const elapsed = ((Date.now() - started) / 1e3).toFixed(1);
4359
+ const header = failedAt === null ? `**Flow: ${okCount}/${args.steps.length} steps OK (${elapsed}s)**` : `**Flow FAILED at step ${failedAt + 1} \u2014 ${okCount}/${args.steps.length} steps OK (${elapsed}s)**`;
4360
+ const parts = [header, lines.join("\n")];
4361
+ if (include !== "none") {
4362
+ try {
4363
+ const els = await describe();
4364
+ const shown = els.filter((e) => e.interactive).slice(0, 30);
4365
+ parts.push(
4366
+ `**Screen now (${shown.length} interactive of ${els.length}):**
4367
+ ${shown.map(formatElement2).join("\n")}`
4368
+ );
4369
+ } catch (e) {
4370
+ parts.push(`**Screen now:** unavailable \u2014 ${cap2(errMsg20(e))}`);
4371
+ }
4372
+ }
4373
+ if (include === "all") {
4374
+ if (toolIds.has("network")) {
4375
+ const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
4376
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
4377
+ const fresh = events.filter((e) => !beforeNetwork.has(e.id)).slice(-15);
4378
+ if (fresh.length) {
4379
+ parts.push(
4380
+ `**Network during this flow (${fresh.length}):**
4381
+ ` + fresh.map(
4382
+ (e) => `${e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705"} ${e.method ?? "GET"} ${e.path ?? e.url ?? "?"} \u2192 ${e.error ?? e.status ?? "pending"}`
4383
+ ).join("\n")
4384
+ );
4385
+ }
4386
+ }
4387
+ if (toolIds.has("console")) {
4388
+ const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
4389
+ const entries = payload?.entries ?? [];
4390
+ const fresh = entries.filter((e) => !beforeConsole.has(e.id) && (e.level === "error" || e.level === "warning")).slice(-10);
4391
+ if (fresh.length) {
4392
+ parts.push(
4393
+ `**Console during this flow (${fresh.length}):**
4394
+ ` + fresh.map((e) => `[${e.level === "error" ? "ERR" : "warn"}] ${cap2(firstLines(e.message ?? ""), 220)}`).join("\n")
4395
+ );
4396
+ }
4397
+ }
4398
+ }
4399
+ return text18(parts.join("\n\n"));
4400
+ } catch (e) {
4401
+ return fail20(errMsg20(e));
4402
+ }
4403
+ }
4404
+ );
4405
+ }
4406
+ function selector(needle) {
4407
+ return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
4408
+ }
4409
+ function describeStep(step) {
4410
+ if (step.tap) return `tap "${step.tap}"`;
4411
+ if (step.type !== void 0) return `type into "${step.into ?? "?"}"`;
4412
+ if (step.reload) return "reload";
4413
+ if (step.waitFor) return `waitFor "${step.waitFor}"`;
4414
+ if (step.waitGone) return `waitGone "${step.waitGone}"`;
4415
+ if (step.expect) return `expect "${step.expect}"`;
4416
+ return "step";
4417
+ }
4418
+ var cap2 = (s, n = 160) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
4419
+ var firstLines = (s, n = 3) => s.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, n).join(" \u23CE ");
4420
+
4421
+ // src/tools/packs/renders.ts
4422
+ var import_zod21 = require("zod");
4423
+ var UI_TOOL_ID2 = "highlight-updates";
4424
+ var text19 = (s) => ({ content: [{ type: "text", text: s }] });
4425
+ var fail21 = (message) => ({
4426
+ isError: true,
4427
+ content: [{ type: "text", text: message }]
4428
+ });
4429
+ var errMsg21 = (e) => e instanceof Error ? e.message : String(e);
4430
+ var delay5 = (ms) => new Promise((r) => setTimeout(r, ms));
4431
+ var DEFAULT_IDLE_MS = 3e3;
4432
+ var SETTLE_MS = 400;
4433
+ var DEFAULT_TOP = 12;
4434
+ var lastCapture = /* @__PURE__ */ new Map();
4435
+ var stepSchema2 = import_zod21.z.object({
4436
+ tap: import_zod21.z.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
4437
+ type: import_zod21.z.string().optional().describe("Text to type (needs `into`)."),
4438
+ into: import_zod21.z.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
4439
+ waitMs: import_zod21.z.number().min(0).max(1e4).optional().describe("Pause after the action, in ms."),
4440
+ repeat: import_zod21.z.number().min(1).max(20).optional().describe("Do this step N times (default 1). Useful for 'tap it five times'.")
4441
+ }).describe("One interaction to measure. Keep it to the thing you're asking about.");
4442
+ function selector2(needle) {
4443
+ return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
4444
+ }
4445
+ function dominant(causes) {
4446
+ if (!causes) return "unknown";
4447
+ let best = "unknown";
4448
+ let n = -1;
4449
+ for (const [k, v] of Object.entries(causes)) {
4450
+ if ((v ?? 0) > n) {
4451
+ n = v ?? 0;
4452
+ best = k;
4453
+ }
4454
+ }
4455
+ return best;
4456
+ }
4457
+ var CAUSE_HINT = {
4458
+ propsUnstable: "props changed by IDENTITY only (a fresh function/object from the parent) \u2014 fix upstream, not in the child",
4459
+ parent: "parent re-rendered and nothing of its own changed \u2014 a pure cascade",
4460
+ props: "props genuinely changed value",
4461
+ hooks: "its own state/hooks changed",
4462
+ mount: "first render"
4463
+ };
4464
+ function formatCapture(c, top) {
4465
+ const s = c.summary;
4466
+ const wasted = s.wastedRenders ?? 0;
4467
+ const pct = s.totalRenders > 0 ? Math.round(wasted / s.totalRenders * 100) : 0;
4468
+ const lines = [];
4469
+ const ms = s.totalRenderMs ?? 0;
4470
+ lines.push(
4471
+ `**${s.totalRenders} component renders across ${s.totalCommits} commits in ${(c.windowMs / 1e3).toFixed(1)}s**` + (ms > 0 ? ` \xB7 ${ms.toFixed(1)}ms of render work` : "") + ` \xB7 ${wasted} wasted (${pct}%)`
4472
+ );
4473
+ const causeLine = Object.entries(s.causes ?? {}).filter(([, v]) => (v ?? 0) > 0).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).map(([k, v]) => `${k} ${v}`).join(" \xB7 ");
4474
+ if (causeLine) lines.push(`causes: ${causeLine}`);
4475
+ lines.push("");
4476
+ lines.push("| component | self ms | renders | cause | dragged along by | changed props |");
4477
+ lines.push("|---|---:|---:|---|---|---|");
4478
+ const rows = [...s.topComponents].sort((a, b) => (b.totalMs ?? 0) - (a.totalMs ?? 0) || b.renders - a.renders).slice(0, top);
4479
+ for (const r of rows) {
4480
+ lines.push(
4481
+ `| ${r.name} | ${(r.totalMs ?? 0).toFixed(1)} | ${r.renders} | ${dominant(r.causes)} | ${r.parentName ?? "\u2014"} | ${(r.changedProps ?? []).join(", ") || "\u2014"} |`
4482
+ );
4483
+ }
4484
+ if (s.topComponents.length > top) {
4485
+ lines.push(`_\u2026+${s.topComponents.length - top} more components_`);
4486
+ }
4487
+ const seen = new Set(rows.map((r) => dominant(r.causes)));
4488
+ const hints = [...seen].filter((k) => CAUSE_HINT[k]).map((k) => `\`${k}\` = ${CAUSE_HINT[k]}`);
4489
+ if (hints.length) {
4490
+ lines.push("");
4491
+ lines.push(hints.join(" \xB7 "));
4492
+ }
4493
+ lines.push("");
4494
+ lines.push(
4495
+ "`wasted` = renders that produced nothing new (parent cascades + identity-only prop changes) \u2014 the removable part. Sorted by **self ms**: fix what costs time, not what renders often. Cheap components re-rendering is usually fine, and blanket `memo()` trades real code clarity for a number that was never the problem."
4496
+ );
4497
+ return lines.join("\n");
4498
+ }
4499
+ function formatDelta(before, after) {
4500
+ const d = (a, b) => {
4501
+ const diff = b - a;
4502
+ return `${a} \u2192 ${b} (${diff > 0 ? "+" : ""}${diff})`;
4503
+ };
4504
+ const bs = before.summary;
4505
+ const as_ = after.summary;
4506
+ const lines = [`**vs "${before.label}"**`];
4507
+ lines.push(`- total renders: ${d(bs.totalRenders, as_.totalRenders)}`);
4508
+ lines.push(`- wasted: ${d(bs.wastedRenders ?? 0, as_.wastedRenders ?? 0)}`);
4509
+ const beforeByName = new Map(bs.topComponents.map((r) => [r.name, r.renders]));
4510
+ const moved = [];
4511
+ for (const r of as_.topComponents.slice(0, 20)) {
4512
+ const was = beforeByName.get(r.name) ?? 0;
4513
+ if (was !== r.renders) moved.push(`${r.name} ${d(was, r.renders)}`);
4514
+ beforeByName.delete(r.name);
4515
+ }
4516
+ for (const [name, was] of beforeByName) {
4517
+ if (was > 0) moved.push(`${name} ${d(was, 0)}`);
4518
+ }
4519
+ if (moved.length) lines.push(`- per component: ${moved.slice(0, 10).join(" \xB7 ")}`);
4520
+ const fewer = as_.totalRenders < bs.totalRenders;
4521
+ const more = as_.totalRenders > bs.totalRenders;
4522
+ lines.push(
4523
+ more ? "\n\u26A0\uFE0F MORE renders than before \u2014 this change made this interaction worse. Re-render fixes routinely move cost instead of removing it; check the other interactions too before moving on." : fewer ? "\n\u2705 Fewer renders than before on this interaction. Measure the OTHER interactions too \u2014 a fix that helps typing can cost taps." : "\n\u2796 No change on this interaction."
4524
+ );
4525
+ return lines.join("\n");
4526
+ }
4527
+ function registerRenderTools(server, sync) {
4528
+ server.registerTool(
4529
+ "measure_renders",
4530
+ {
4531
+ description: "Find out WHAT re-rendered on the current screen and WHY, in one call \u2014 no test route, no scaffolding. THE tool for 'this page is slow/laggy/janky', 'typing lags', 'the whole page re-renders when I tap one button'. Give it `steps` to measure a specific interaction (tap a button, type in a field) \u2014 measuring an idle window instead tells you nothing about what taps cost, which is how re-render fixes usually go wrong. Returns per-component render COST in ms (sorted by it \u2014 a component rendering 40x for 0.4ms is noise, one rendering 4x for 38ms is the answer), the CAUSE of each (parent cascade / own state / changed props), which parent dragged it along, and which props changed. `wasted` counts renders that produced nothing new \u2014 that's the removable part. AFTER YOU EDIT, call it again with the same steps and compareToPrevious:true to prove the fix landed and didn't move the cost somewhere else. (For choosing between several implementations of a screen, use run_benchmark_batch instead \u2014 that ranks variants; this explains one screen.) Requires Buoy Pro and @buoy-gg/highlight-updates.",
4532
+ inputSchema: {
4533
+ steps: import_zod21.z.array(stepSchema2).max(20).optional().describe(
4534
+ "The interaction to measure, e.g. [{tap:'favorite-deal-2', repeat:5}] or [{type:'bench', into:'deals-search'}]. Omit to sample the screen idle \u2014 only useful for churn that happens with nobody touching it."
4535
+ ),
4536
+ idleMs: import_zod21.z.number().min(500).max(2e4).optional().describe(`Idle sample length when no steps are given. Default ${DEFAULT_IDLE_MS}.`),
4537
+ label: import_zod21.z.string().optional().describe('Name this measurement, e.g. "before fix". Shown in the next comparison.'),
4538
+ compareToPrevious: import_zod21.z.boolean().optional().describe("Diff against the previous measure_renders on this device. Use after an edit to prove the fix."),
4539
+ top: import_zod21.z.number().min(1).max(40).optional().describe(`Components to list. Default ${DEFAULT_TOP}.`),
4540
+ deviceId: import_zod21.z.string().optional().describe("Target device id (auto-selected if one is connected).")
4541
+ }
4542
+ },
4543
+ async (args) => {
4544
+ try {
4545
+ const target = sync.requireProDevice(args.deviceId);
4546
+ const device = sync.connectedDevices().find((d) => d.deviceId === target);
4547
+ const uiTool = (device?.tools ?? []).find((t) => t.toolId === UI_TOOL_ID2);
4548
+ if (!uiTool) {
4549
+ return fail21(
4550
+ "This app doesn't expose the highlight-updates tool, so renders can't be measured. Install @buoy-gg/highlight-updates."
4551
+ );
4552
+ }
4553
+ if (!(uiTool.actions ?? []).includes("beginMeasurement")) {
4554
+ return fail21(
4555
+ "This app's @buoy-gg/highlight-updates is too old to measure renders (needs the version with beginMeasurement). Upgrade it, then try again. Meanwhile run_benchmark_batch with captureRenders can still give counts if you have a test route."
4556
+ );
4557
+ }
4558
+ const began = await sync.callAction(UI_TOOL_ID2, "beginMeasurement", {}, target);
4559
+ if (began && began.ok === false) {
4560
+ return fail21(`Can't measure renders: ${began.reason ?? "capture unavailable"}`);
4561
+ }
4562
+ await delay5(SETTLE_MS);
4563
+ const startedAt = Date.now();
4564
+ const notes = [];
4565
+ if (args.steps?.length) {
4566
+ for (const step of args.steps) {
4567
+ const times = step.repeat ?? 1;
4568
+ for (let i = 0; i < times; i++) {
4569
+ if (step.type !== void 0) {
4570
+ const sel = step.into ?? step.tap;
4571
+ if (!sel) {
4572
+ notes.push("a `type` step needs `into`");
4573
+ continue;
4574
+ }
4575
+ const res = await sync.callAction(
4576
+ UI_TOOL_ID2,
4577
+ "tapElement",
4578
+ { ...selector2(sel), text: step.type },
4579
+ target
4580
+ );
4581
+ if (!res?.tapped) notes.push(res?.reason ?? `could not type into "${sel}"`);
4582
+ else if (res.matched?.via && res.matched.via !== "onChangeText") {
4583
+ notes.push(`"${sel}" is not a text input \u2014 it ran ${res.matched.via}, nothing was typed`);
4584
+ }
4585
+ } else if (step.tap) {
4586
+ const res = await sync.callAction(
4587
+ UI_TOOL_ID2,
4588
+ "tapElement",
4589
+ selector2(step.tap),
4590
+ target
4591
+ );
4592
+ if (!res?.tapped) notes.push(res?.reason ?? `no element matched "${step.tap}"`);
4593
+ }
4594
+ if (step.waitMs) await delay5(step.waitMs);
4595
+ }
4596
+ }
4597
+ await delay5(SETTLE_MS);
4598
+ } else {
4599
+ await delay5(args.idleMs ?? DEFAULT_IDLE_MS);
4600
+ }
4601
+ const windowMs = Date.now() - startedAt;
4602
+ const ended = await sync.callAction(UI_TOOL_ID2, "endMeasurement", {}, target);
4603
+ const summary = ended?.summary;
4604
+ if (!summary) {
4605
+ return fail21(
4606
+ "The capture window closed without returning anything. If the app reloaded mid-measurement, try again."
4607
+ );
4608
+ }
4609
+ const capture = {
4610
+ label: args.label ?? (args.steps?.length ? "interaction" : "idle"),
4611
+ windowMs,
4612
+ summary
4613
+ };
4614
+ const prev = lastCapture.get(target);
4615
+ lastCapture.set(target, capture);
4616
+ const what = args.steps?.length ? args.steps.map((s) => (s.type !== void 0 ? `type "${s.type}"` : `tap "${s.tap}"`) + (s.repeat ? ` \xD7${s.repeat}` : "")).join(", ") : `idle ${(windowMs / 1e3).toFixed(1)}s`;
4617
+ const parts = [`**measure_renders \u2014 ${what}**`, "", formatCapture(capture, args.top ?? DEFAULT_TOP)];
4618
+ if (args.compareToPrevious) {
4619
+ parts.push("", prev ? formatDelta(prev, capture) : "_No previous measurement on this device to compare against._");
4620
+ }
4621
+ if (notes.length) parts.push("", `\u26A0\uFE0F ${notes.join("; ")}`);
4622
+ if (summary.totalRenders === 0) {
4623
+ parts.push(
4624
+ "",
4625
+ "_Nothing re-rendered in this window. If you expected renders, check the app is on the screen you meant and that the steps actually hit something._"
4626
+ );
4627
+ } else if (!args.compareToPrevious) {
4628
+ parts.push("", "_Fixed something? Call this again with the same steps and `compareToPrevious:true` to prove it landed._");
4629
+ }
4630
+ return text19(parts.join("\n"));
4631
+ } catch (e) {
4632
+ return fail21(errMsg21(e));
4633
+ }
4634
+ }
4635
+ );
4636
+ }
4637
+
4638
+ // src/verify/editWatcher.ts
4639
+ var import_node_fs3 = __toESM(require("fs"));
4640
+ var import_node_path3 = __toESM(require("path"));
4641
+ var WATCHED_EXT = /* @__PURE__ */ new Set([
4642
+ ".ts",
4643
+ ".tsx",
4644
+ ".js",
4645
+ ".jsx",
4646
+ ".mjs",
4647
+ ".cjs",
4648
+ ".json"
4649
+ ]);
4650
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
4651
+ "node_modules",
4652
+ ".git",
4653
+ ".expo",
4654
+ "ios",
4655
+ "android",
4656
+ "dist",
4657
+ "lib",
4658
+ "build",
4659
+ ".next",
4660
+ "coverage",
4661
+ "__snapshots__"
4662
+ ]);
4663
+ var DEBOUNCE_MS = 400;
4664
+ function resolveMode(raw) {
4665
+ const v = (raw ?? "").trim().toLowerCase();
4666
+ if (v === "always" || v === "never" || v === "auto") return v;
4667
+ return "auto";
4668
+ }
4669
+ var EditWatcher = class {
4670
+ constructor() {
4671
+ this.watcher = null;
4672
+ this.changed = /* @__PURE__ */ new Set();
4673
+ this.lastEditAt = 0;
4674
+ this.nudgedForCurrentBatch = false;
4675
+ this.mode = "auto";
4676
+ this.started = false;
4677
+ }
4678
+ start(rootDir, mode) {
4679
+ this.mode = mode;
4680
+ if (mode === "never" || this.started) return;
4681
+ this.started = true;
4682
+ try {
4683
+ this.watcher = import_node_fs3.default.watch(
4684
+ rootDir,
4685
+ { recursive: true },
4686
+ (_event, filename) => {
4687
+ if (!filename) return;
4688
+ const rel2 = String(filename);
4689
+ if (rel2.split(import_node_path3.default.sep).some((seg) => IGNORED_DIRS.has(seg))) return;
4690
+ if (!WATCHED_EXT.has(import_node_path3.default.extname(rel2))) return;
4691
+ this.noteEdit(rel2);
4692
+ }
4693
+ );
4694
+ this.watcher.on("error", () => this.stop());
4695
+ } catch {
4696
+ this.stop();
4697
+ }
4698
+ }
4699
+ /** Record an edit. Public so the nudge policy can be tested without fs.watch. */
4700
+ noteEdit(rel2) {
4701
+ const now = Date.now();
4702
+ if (now - this.lastEditAt > DEBOUNCE_MS) this.nudgedForCurrentBatch = false;
4703
+ this.lastEditAt = now;
4704
+ if (this.changed.size < 50) this.changed.add(rel2);
4705
+ }
4706
+ stop() {
4707
+ try {
4708
+ this.watcher?.close();
4709
+ } catch {
4710
+ }
4711
+ this.watcher = null;
4712
+ }
4713
+ /** Something on the device was actually checked — the edits are accounted for. */
4714
+ markVerified() {
4715
+ this.changed.clear();
4716
+ this.nudgedForCurrentBatch = false;
4717
+ }
4718
+ hasUnverifiedEdits() {
4719
+ return this.changed.size > 0;
4720
+ }
4721
+ /**
4722
+ * The line to append to a tool result, or null to stay quiet.
4723
+ *
4724
+ * `auto` says it once per burst of edits, so it lands when the agent is
4725
+ * mid-loop and then gets out of the way. `always` repeats while anything is
4726
+ * unverified, for people who want the belt and braces.
4727
+ */
4728
+ nudge() {
4729
+ if (this.mode === "never" || !this.hasUnverifiedEdits()) return null;
4730
+ if (this.mode === "auto") {
4731
+ if (this.nudgedForCurrentBatch) return null;
4732
+ this.nudgedForCurrentBatch = true;
4733
+ }
4734
+ const files = [...this.changed];
4735
+ const shown = files.slice(0, 3).join(", ");
4736
+ const more = files.length > 3 ? ` +${files.length - 3} more` : "";
4737
+ return `
4738
+
4739
+ ---
4740
+ \u26A0\uFE0F **${files.length} source file${files.length === 1 ? "" : "s"} changed since anything was last checked on the device** (${shown}${more}).
4741
+ Before reporting this as fixed, observe it working: re-run the same interaction with \`measure_renders\` (\`compareToPrevious: true\`) or \`run_flow\`, and say what the numbers did. A fix nobody watched work is a guess \u2014 and re-render fixes in particular tend to move cost somewhere else rather than remove it.`;
4742
+ }
4743
+ };
4744
+ var editWatcher = new EditWatcher();
4745
+
3574
4746
  // src/server.ts
3575
4747
  var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
3576
4748
 
3577
4749
  Buoy tools live inside the app and sync to a local broker; this server connects to that
3578
- broker as a dashboard client. Start by calling \`list_devices\` to see which device(s) are
3579
- connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose.
4750
+ broker as a dashboard client.
4751
+
4752
+ DEBUGGING A RUNNING APP? Call \`get_triage\` FIRST \u2014 one call returns the connected device,
4753
+ recent console errors (including [FATAL] crashes), recent network requests with statuses,
4754
+ persisted storage keys, and what's on screen. It replaces the usual five-call opening
4755
+ sequence and tells you which tool to drill into. Read runtime facts before hypothesizing
4756
+ from source: the app's actual state beats a guess about what the code should do.
4757
+
4758
+ Routing after triage \u2014 match the SYMPTOM to the tool:
4759
+ - Value wrong/stale/NaN, or broken only after an upgrade \u2192 \`get_storage includeValues:true\`
4760
+ (a bad persisted value is a top root cause, and it survives reinstalls of the JS).
4761
+ - Failed request, wrong data from the API, auth/session bug \u2192 \`get_network_requests
4762
+ includeBodies:true\` and read the response BODY.
4763
+ - Crash or redbox \u2192 \`get_console\` (fatals are captured, and still readable after the app
4764
+ dies), then \`get_storage\` for the state that triggered it.
4765
+ - Data on screen doesn't match the API \u2192 \`get_react_query\` / \`get_zustand_state\` /
4766
+ \`get_redux_state\` / \`get_jotai_state\` for the cache or store between them.
4767
+
4768
+ AFTER YOU EDIT, PROVE IT on the device \u2014 the most common way a fix fails is being incomplete,
4769
+ not being wrong. Reload (\`run_flow\` step \`{reload:true}\`, or \`reload_app\`) and re-drive EVERY
4770
+ screen the symptom could appear on, asserting the corrected value: a price shown in three places
4771
+ has to be right in all three, and a migration only counts if it survives a restart. A fix you
4772
+ haven't watched work is a guess.
4773
+
4774
+ Use \`list_devices\` when you need the full tool/action inventory or several devices are
4775
+ connected (\`get_triage\` already names the active one).
3580
4776
 
3581
4777
  To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
3582
4778
  react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
@@ -3595,15 +4791,28 @@ Need to SEE a component (verify a UI change, check styling/layout)? Use \`screen
3595
4791
  a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
3596
4792
  iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
3597
4793
 
3598
- Need to DRIVE the UI (tap a button, follow a flow)? Use \`describe_screen\` to list the on-screen
3599
- elements (accessibility-style tree with tap points + testIDs, no screenshot), then \`tap_element\`
3600
- ({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
3601
- it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
4794
+ Need to DRIVE the UI? Reach for \`run_flow\` FIRST: it runs a whole scripted sequence
4795
+ (tap / type / set / reload / waitFor / expect) in ONE call and reports the per-step result, the
4796
+ final screen, and the network + console output the flow produced. Reproducing a bug and verifying
4797
+ a fix are one call each instead of a dozen alternating reads and taps, and a failed step reports
4798
+ the screen at the point it broke. Drop to \`describe_screen\` (list on-screen elements with testIDs
4799
+ and tap points) + \`tap_element\` only for one-off pokes or when you don't yet know what's there.
4800
+
4801
+ Screen slow / laggy / janky / stutters, typing lags, or "the whole page re-renders when I tap
4802
+ one thing"? Start with \`measure_renders\` \u2014 one call, no test route needed. Pass \`steps\` for the
4803
+ interaction you're actually asking about (a tap, some typing); an idle sample says nothing about
4804
+ what a tap costs. It returns per-component render counts, WHY each rendered (parent cascade / own
4805
+ state / changed props), which parent dragged it along, and \`wasted\` \u2014 the renders that produced
4806
+ nothing new. After you edit, call it again with the same steps and \`compareToPrevious:true\`:
4807
+ re-render fixes routinely move the cost somewhere else instead of removing it, and only a second
4808
+ measurement catches that.
3602
4809
 
3603
4810
  JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
3604
4811
  \`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
3605
4812
  freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
3606
4813
  Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
4814
+ This is about TASKS on the thread \u2014 for React components re-rendering too often, it's
4815
+ \`measure_renders\`, not this.
3607
4816
 
3608
4817
  Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
3609
4818
  expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
@@ -3639,11 +4848,46 @@ If no device is connected, open the app with Buoy devtools running and try again
3639
4848
  The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
3640
4849
  require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
3641
4850
  required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
4851
+ var VERIFYING_TOOLS = /* @__PURE__ */ new Set([
4852
+ "measure_renders",
4853
+ "run_flow",
4854
+ "run_benchmark_batch",
4855
+ "get_triage",
4856
+ "tap_element",
4857
+ "describe_screen",
4858
+ "get_console",
4859
+ "get_network_requests",
4860
+ "get_events",
4861
+ "get_snapshot"
4862
+ ]);
3642
4863
  function createMcpServer(sync) {
3643
4864
  const server = new import_mcp.McpServer(
3644
4865
  { name: "buoy", version: VERSION },
3645
4866
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
3646
4867
  );
4868
+ editWatcher.start(process.cwd(), resolveMode(process.env.BUOY_VERIFY));
4869
+ const originalRegister = server.registerTool.bind(server);
4870
+ server.registerTool = ((name, config, handler) => originalRegister(name, config, (async (...args) => {
4871
+ const result = await handler(...args);
4872
+ const errored = result?.isError === true;
4873
+ if (VERIFYING_TOOLS.has(name) && !errored) {
4874
+ editWatcher.markVerified();
4875
+ return result;
4876
+ }
4877
+ const note = editWatcher.nudge();
4878
+ if (!note) return result;
4879
+ const r = result;
4880
+ if (Array.isArray(r?.content)) {
4881
+ return {
4882
+ ...r,
4883
+ content: [...r.content, { type: "text", text: note }]
4884
+ };
4885
+ }
4886
+ return result;
4887
+ })));
4888
+ registerTriageTools(server, sync);
4889
+ registerFlowTools(server, sync);
4890
+ registerRenderTools(server, sync);
3647
4891
  registerGenericTools(server, sync);
3648
4892
  registerPerfMonitorTools(server, sync);
3649
4893
  registerEventsTools(server, sync);
@@ -3665,34 +4909,37 @@ function createMcpServer(sync) {
3665
4909
  return server;
3666
4910
  }
3667
4911
  var deviceIdField = {
3668
- deviceId: import_zod19.z.string().optional().describe(
4912
+ deviceId: import_zod22.z.string().optional().describe(
3669
4913
  'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
3670
4914
  )
3671
4915
  };
3672
4916
 
3673
4917
  // src/init.ts
3674
4918
  var import_node_child_process2 = require("child_process");
3675
- var import_node_fs3 = __toESM(require("fs"));
4919
+ var import_node_fs4 = __toESM(require("fs"));
3676
4920
  var import_node_os3 = __toESM(require("os"));
3677
- var import_node_path3 = __toESM(require("path"));
4921
+ var import_node_path4 = __toESM(require("path"));
4922
+ function defaultEnv() {
4923
+ return { BUOY_VERIFY: "auto" };
4924
+ }
3678
4925
  var PUBLIC_REGISTRY = "https://registry.npmjs.org";
3679
- var PROBE_TIMEOUT_MS2 = 3e3;
4926
+ var PROBE_TIMEOUT_MS3 = 3e3;
3680
4927
  function runInit(opts = {}) {
3681
4928
  const cwd = opts.cwd ?? process.cwd();
3682
- const skillSrcDir = opts.skillSrcDir ?? import_node_path3.default.resolve(__dirname, "../skill");
4929
+ const skillSrcDir = opts.skillSrcDir ?? import_node_path4.default.resolve(__dirname, "../skill");
3683
4930
  const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
3684
4931
  const entry = resolveEntry(opts, log);
3685
4932
  const done = [];
3686
- upsertMcpConfig(import_node_path3.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
3687
- if (import_node_fs3.default.existsSync(import_node_path3.default.join(cwd, ".cursor"))) {
3688
- upsertMcpConfig(import_node_path3.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
4933
+ upsertMcpConfig(import_node_path4.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
4934
+ if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".cursor"))) {
4935
+ upsertMcpConfig(import_node_path4.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
3689
4936
  }
3690
- if (import_node_fs3.default.existsSync(import_node_path3.default.join(cwd, ".vscode"))) {
3691
- upsertMcpConfig(import_node_path3.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
4937
+ if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".vscode"))) {
4938
+ upsertMcpConfig(import_node_path4.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
3692
4939
  }
3693
4940
  installSkills(skillSrcDir, cwd, done);
3694
- appendDebuggingGuide(import_node_path3.default.join(cwd, "CLAUDE.md"), cwd, done);
3695
- appendDebuggingGuide(import_node_path3.default.join(cwd, "AGENTS.md"), cwd, done);
4941
+ appendDebuggingGuide(import_node_path4.default.join(cwd, "CLAUDE.md"), cwd, done);
4942
+ appendDebuggingGuide(import_node_path4.default.join(cwd, "AGENTS.md"), cwd, done);
3696
4943
  log("Buoy MCP installed:");
3697
4944
  for (const line of done) log(" \u2022 " + line);
3698
4945
  log("");
@@ -3712,7 +4959,7 @@ function runInit(opts = {}) {
3712
4959
  }
3713
4960
  function resolveEntry(opts, log) {
3714
4961
  if (opts.cliPath) {
3715
- return { type: "stdio", command: "node", args: [opts.cliPath] };
4962
+ return { type: "stdio", command: "node", args: [opts.cliPath], env: defaultEnv() };
3716
4963
  }
3717
4964
  const mode = opts.mode ?? "auto";
3718
4965
  if (mode === "npx") {
@@ -3731,21 +4978,21 @@ function resolveEntry(opts, log) {
3731
4978
  return localEntry(opts, log);
3732
4979
  }
3733
4980
  function npxEntry() {
3734
- return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
4981
+ return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`], env: defaultEnv() };
3735
4982
  }
3736
4983
  function localEntry(opts, log) {
3737
- const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path3.default.join(import_node_os3.default.homedir(), ".buoy-mcp");
4984
+ const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path4.default.join(import_node_os3.default.homedir(), ".buoy-mcp");
3738
4985
  const registry = opts.registry ?? PUBLIC_REGISTRY;
3739
4986
  const cliPath = installLocalCopy(installDir, registry, log);
3740
4987
  if (cliPath) {
3741
- return { type: "stdio", command: "node", args: [cliPath] };
4988
+ return { type: "stdio", command: "node", args: [cliPath], env: defaultEnv() };
3742
4989
  }
3743
4990
  log("\u26A0 local install failed \u2014 writing the npx entry instead (see the error above).");
3744
4991
  return npxEntry();
3745
4992
  }
3746
4993
  function installLocalCopy(installDir, registry, log) {
3747
4994
  const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
3748
- import_node_fs3.default.mkdirSync(installDir, { recursive: true });
4995
+ import_node_fs4.default.mkdirSync(installDir, { recursive: true });
3749
4996
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
3750
4997
  log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
3751
4998
  const res = (0, import_node_child_process2.spawnSync)(
@@ -3768,8 +5015,8 @@ function installLocalCopy(installDir, registry, log) {
3768
5015
  log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
3769
5016
  return null;
3770
5017
  }
3771
- const cliPath = import_node_path3.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
3772
- if (!import_node_fs3.default.existsSync(cliPath)) {
5018
+ const cliPath = import_node_path4.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
5019
+ if (!import_node_fs4.default.existsSync(cliPath)) {
3773
5020
  log(`\u26A0 install finished but ${cliPath} is missing.`);
3774
5021
  return null;
3775
5022
  }
@@ -3791,7 +5038,7 @@ function effectiveRegistry() {
3791
5038
  }
3792
5039
  function npmConfigGet(key) {
3793
5040
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
3794
- const res = (0, import_node_child_process2.spawnSync)(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS2 });
5041
+ const res = (0, import_node_child_process2.spawnSync)(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 });
3795
5042
  if (res.status !== 0 || typeof res.stdout !== "string") return null;
3796
5043
  const val = res.stdout.trim();
3797
5044
  if (!val || val === "undefined" || val === "null") return null;
@@ -3800,8 +5047,8 @@ function npmConfigGet(key) {
3800
5047
  function hostReachable(registry) {
3801
5048
  const done = (0, import_node_child_process2.spawnSync)(
3802
5049
  process.execPath,
3803
- ["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS2)],
3804
- { encoding: "utf8", timeout: PROBE_TIMEOUT_MS2 + 2e3 }
5050
+ ["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS3)],
5051
+ { encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 + 2e3 }
3805
5052
  );
3806
5053
  return done.status === 0;
3807
5054
  }
@@ -3821,22 +5068,22 @@ fetch(url, { method: 'HEAD', signal: c.signal })
3821
5068
  `;
3822
5069
  function upsertMcpConfig(file, key, entry, cwd, done) {
3823
5070
  let json3 = {};
3824
- if (import_node_fs3.default.existsSync(file)) {
5071
+ if (import_node_fs4.default.existsSync(file)) {
3825
5072
  try {
3826
- json3 = JSON.parse(import_node_fs3.default.readFileSync(file, "utf8"));
5073
+ json3 = JSON.parse(import_node_fs4.default.readFileSync(file, "utf8"));
3827
5074
  } catch {
3828
- import_node_fs3.default.copyFileSync(file, file + ".bak");
5075
+ import_node_fs4.default.copyFileSync(file, file + ".bak");
3829
5076
  done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
3830
5077
  json3 = {};
3831
5078
  }
3832
5079
  } else {
3833
- import_node_fs3.default.mkdirSync(import_node_path3.default.dirname(file), { recursive: true });
5080
+ import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(file), { recursive: true });
3834
5081
  }
3835
5082
  const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
3836
5083
  const existed = "buoy" in servers;
3837
5084
  servers.buoy = entry;
3838
5085
  json3[key] = servers;
3839
- import_node_fs3.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
5086
+ import_node_fs4.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
3840
5087
  done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
3841
5088
  }
3842
5089
  var GUIDE_HEADING = "# Debugging with Buoy";
@@ -3845,6 +5092,13 @@ var DEBUGGING_GUIDE = `${GUIDE_HEADING}
3845
5092
  This app's runtime is inspectable while it runs: the Buoy MCP tools read live
3846
5093
  state straight from the device. Route by symptom before reading source:
3847
5094
 
5095
+ - \`get_triage\` \u2014 START HERE. One call: device, recent console errors and
5096
+ crashes, recent network requests, storage keys, and what's on screen. It
5097
+ usually names the problem, and always names the tool to drill into next.
5098
+ - \`run_flow\` \u2014 reproduce a bug or verify a fix in ONE call: a scripted
5099
+ tap/type/wait/assert sequence that returns the final screen plus the network
5100
+ and console output it produced. Use it instead of alternating
5101
+ \`describe_screen\` and \`tap_element\` a dozen times.
3848
5102
  - \`get_events\` \u2014 the universal activity timeline (network, state changes,
3849
5103
  storage writes, routes, renders). Check it BEFORE driving the UI to
3850
5104
  reproduce a bug \u2014 the failure is usually already recorded.
@@ -3852,19 +5106,46 @@ state straight from the device. Route by symptom before reading source:
3852
5106
  request + response bodies instead of guessing from code. Boot-time requests
3853
5107
  are captured too.
3854
5108
  - \`get_console\` \u2014 crashes and errors, including FATAL uncaught JS errors
3855
- (tagged \`[FATAL]\`).
5109
+ (tagged \`[FATAL]\`, with the component that crashed). Readable even after the
5110
+ app dies, so a redboxed app still tells you why.
3856
5111
  - \`get_storage\` \u2014 data wrong or missing after an app restart \u2192 read the
3857
5112
  persisted values FIRST.
5113
+ - \`reload_app\` \u2014 recover a crashed app (the other tools stop answering until
5114
+ you do) or pick up a change Fast Refresh missed.
3858
5115
  - \`get_snapshot\` \u2014 full current state of any one tool when the compact views
3859
5116
  aren't enough.
3860
5117
 
3861
- Runtime facts beat guesses.
5118
+ Runtime facts beat guesses \u2014 and after you edit, prove the fix on the device:
5119
+ reload, then re-drive EVERY screen the symptom could show up on and assert the
5120
+ corrected value. Fixes usually fail by being incomplete, not by being wrong.
5121
+
5122
+ ## Don't report a fix you haven't watched work
5123
+
5124
+ Saying "fixed" is a claim about how the app behaves, so check it the same way
5125
+ you'd check anything else about the app \u2014 by looking at it. Re-run the exact
5126
+ interaction that was broken and report what the numbers did:
5127
+
5128
+ - \`measure_renders\` with the same \`steps\` and \`compareToPrevious: true\` for a
5129
+ performance change \u2014 it prints the before/after delta and flags a change that
5130
+ made things worse.
5131
+ - \`run_flow\` re-driving the broken screen for a behaviour change.
5132
+
5133
+ Two things this catches that reading cannot: a fix that is right but incomplete
5134
+ (the value was wrong in three places and you corrected one), and a fix that
5135
+ moved the cost instead of removing it \u2014 the usual failure mode for re-render
5136
+ work, where a page gets faster to type in and slower to tap.
5137
+
5138
+ If the app changed since anything was last checked, Buoy will say so in a tool
5139
+ result. Whether that's worth acting on is your call \u2014 a doc edit isn't worth a
5140
+ device check, a state-management change is. Set \`BUOY_VERIFY\` in the MCP config
5141
+ to \`always\` (repeat until checked) or \`never\` (silent) to change how often it
5142
+ mentions it.
3862
5143
  `;
3863
5144
  function appendDebuggingGuide(file, cwd, done) {
3864
5145
  let existing = "";
3865
- if (import_node_fs3.default.existsSync(file)) {
5146
+ if (import_node_fs4.default.existsSync(file)) {
3866
5147
  try {
3867
- existing = import_node_fs3.default.readFileSync(file, "utf8");
5148
+ existing = import_node_fs4.default.readFileSync(file, "utf8");
3868
5149
  } catch {
3869
5150
  done.push(`\u26A0 could not read ${rel(file, cwd)} \u2014 skipped the debugging guide`);
3870
5151
  return;
@@ -3876,40 +5157,40 @@ function appendDebuggingGuide(file, cwd, done) {
3876
5157
  }
3877
5158
  const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3878
5159
  try {
3879
- import_node_fs3.default.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
5160
+ import_node_fs4.default.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
3880
5161
  done.push(`${existing ? "appended" : "created"} "${GUIDE_HEADING}" block in ${rel(file, cwd)}`);
3881
5162
  } catch {
3882
5163
  done.push(`\u26A0 could not write ${rel(file, cwd)} \u2014 skipped the debugging guide`);
3883
5164
  }
3884
5165
  }
3885
5166
  function installSkills(skillSrcDir, cwd, done) {
3886
- if (!import_node_fs3.default.existsSync(skillSrcDir)) {
5167
+ if (!import_node_fs4.default.existsSync(skillSrcDir)) {
3887
5168
  done.push("\u26A0 no bundled skills found \u2014 skipped");
3888
5169
  return;
3889
5170
  }
3890
- const skills = import_node_fs3.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs3.default.existsSync(import_node_path3.default.join(skillSrcDir, e.name, "SKILL.md")));
5171
+ const skills = import_node_fs4.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs4.default.existsSync(import_node_path4.default.join(skillSrcDir, e.name, "SKILL.md")));
3891
5172
  if (skills.length === 0) {
3892
5173
  done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
3893
5174
  return;
3894
5175
  }
3895
5176
  for (const skill of skills) {
3896
- const from = import_node_path3.default.join(skillSrcDir, skill.name);
3897
- const to = import_node_path3.default.join(cwd, ".claude", "skills", skill.name);
5177
+ const from = import_node_path4.default.join(skillSrcDir, skill.name);
5178
+ const to = import_node_path4.default.join(cwd, ".claude", "skills", skill.name);
3898
5179
  copyDir(from, to);
3899
- done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path3.default.join(to, "SKILL.md"), cwd)}`);
5180
+ done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path4.default.join(to, "SKILL.md"), cwd)}`);
3900
5181
  }
3901
5182
  }
3902
5183
  function copyDir(from, to) {
3903
- import_node_fs3.default.mkdirSync(to, { recursive: true });
3904
- for (const entry of import_node_fs3.default.readdirSync(from, { withFileTypes: true })) {
3905
- const s = import_node_path3.default.join(from, entry.name);
3906
- const d = import_node_path3.default.join(to, entry.name);
5184
+ import_node_fs4.default.mkdirSync(to, { recursive: true });
5185
+ for (const entry of import_node_fs4.default.readdirSync(from, { withFileTypes: true })) {
5186
+ const s = import_node_path4.default.join(from, entry.name);
5187
+ const d = import_node_path4.default.join(to, entry.name);
3907
5188
  if (entry.isDirectory()) copyDir(s, d);
3908
- else import_node_fs3.default.copyFileSync(s, d);
5189
+ else import_node_fs4.default.copyFileSync(s, d);
3909
5190
  }
3910
5191
  }
3911
5192
  function rel(file, cwd) {
3912
- const r = import_node_path3.default.relative(cwd, file);
5193
+ const r = import_node_path4.default.relative(cwd, file);
3913
5194
  return r && !r.startsWith("..") ? r : file;
3914
5195
  }
3915
5196