@buoy-gg/mcp 7.0.20 → 7.0.22

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.
@@ -3,7 +3,12 @@ import { DEFAULT_BROKER_PORT } from "@buoy-gg/sync-broker";
3
3
  var BROKER_PORT = DEFAULT_BROKER_PORT;
4
4
  var BROKER_URL = `http://localhost:${BROKER_PORT}`;
5
5
  var DEFAULT_ACTION_TIMEOUT_MS = 15e3;
6
+ var UNRESPONSIVE_ACTION_TIMEOUT_MS = 2500;
7
+ var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS = 1200;
8
+ var RECOVERY_PROBE_MS = 2e4;
9
+ var UNRESPONSIVE_AFTER_MS = 3e4;
6
10
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 5e3;
11
+ var DEVICE_RETENTION_MS = 15 * 6e4;
7
12
  var DASHBOARD_NAME = "Dashboard";
8
13
  var ALL_DEVICES = "All";
9
14
 
@@ -34,6 +39,16 @@ var SyncClient = class {
34
39
  this.pendingActions = /* @__PURE__ */ new Map();
35
40
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
36
41
  this.snapshotWaiters = /* @__PURE__ */ new Map();
42
+ /** When each cached snapshot arrived, keyed by snapKey (staleness reporting). */
43
+ this.snapshotAt = /* @__PURE__ */ new Map();
44
+ /** Liveness per device — see {@link noteTimeout}. */
45
+ this.health = /* @__PURE__ */ new Map();
46
+ /** Dev-server origin per device — see {@link noteDevServerOrigin}. */
47
+ this.devServerOrigins = /* @__PURE__ */ new Map();
48
+ /** Devices whose origin probe already ran (a production bundle has none). */
49
+ this.originProbed = /* @__PURE__ */ new Set();
50
+ /** Last time each device appeared in the broker's device list. */
51
+ this.lastSeen = /* @__PURE__ */ new Map();
37
52
  this.port = opts.port ?? BROKER_PORT;
38
53
  this.url = opts.url ?? (opts.port ? `http://localhost:${opts.port}` : BROKER_URL);
39
54
  this.actionTimeoutMs = opts.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
@@ -93,13 +108,19 @@ var SyncClient = class {
93
108
  });
94
109
  socket.on("devtool-capabilities", (msg) => {
95
110
  this.capabilities.set(msg.persistentDeviceId, msg);
111
+ void this.learnDevServerOrigin(msg.persistentDeviceId);
96
112
  });
97
113
  socket.on("all-devices-update", (list) => {
98
- if (Array.isArray(list)) this.devices = list;
114
+ if (!Array.isArray(list)) return;
115
+ this.devices = list;
116
+ const now = Date.now();
117
+ for (const d of list) if (d.deviceId) this.lastSeen.set(d.deviceId, now);
118
+ this.pruneStaleDevices();
99
119
  });
100
120
  socket.on("devtool-sync", (msg) => {
101
121
  const key = snapKey(msg.persistentDeviceId, msg.toolId);
102
122
  this.snapshots.set(key, msg.payload);
123
+ this.snapshotAt.set(key, Date.now());
103
124
  const waiters = this.snapshotWaiters.get(key);
104
125
  if (waiters && waiters.length) {
105
126
  this.snapshotWaiters.set(key, []);
@@ -115,6 +136,37 @@ var SyncClient = class {
115
136
  });
116
137
  });
117
138
  }
139
+ /**
140
+ * Drop everything remembered about devices that have been gone a while.
141
+ *
142
+ * Deliberately NOT on disconnect: a crashed app often drops off the broker,
143
+ * and its cached console snapshot is the crash evidence — throwing that away
144
+ * the instant it disconnects would delete the answer. But device ids change
145
+ * with every simulator erase and fresh dev client, and each one can hold
146
+ * multi-MB snapshots, so a long-lived server has to forget eventually.
147
+ */
148
+ pruneStaleDevices() {
149
+ const cutoff = Date.now() - DEVICE_RETENTION_MS;
150
+ for (const [deviceId, seen] of this.lastSeen) {
151
+ if (seen > cutoff) continue;
152
+ if (this.devices.some((d) => d.deviceId === deviceId)) continue;
153
+ this.lastSeen.delete(deviceId);
154
+ this.capabilities.delete(deviceId);
155
+ this.health.delete(deviceId);
156
+ this.devServerOrigins.delete(deviceId);
157
+ this.originProbed.delete(deviceId);
158
+ const prefix = `${deviceId}::`;
159
+ for (const key of [...this.snapshots.keys()]) {
160
+ if (key.startsWith(prefix)) {
161
+ this.snapshots.delete(key);
162
+ this.snapshotAt.delete(key);
163
+ }
164
+ }
165
+ for (const key of [...this.watching.keys()]) {
166
+ if (key.startsWith(prefix)) this.watching.delete(key);
167
+ }
168
+ }
169
+ }
118
170
  /** Disconnect and tear down a spawned broker, if any. */
119
171
  async stop() {
120
172
  for (const [, p] of this.pendingActions) {
@@ -122,6 +174,8 @@ var SyncClient = class {
122
174
  p.reject(new Error("SyncClient stopped"));
123
175
  }
124
176
  this.pendingActions.clear();
177
+ for (const [, waiters] of this.snapshotWaiters) for (const w of waiters) w(void 0);
178
+ this.snapshotWaiters.clear();
125
179
  this.socket?.close();
126
180
  this.socket = null;
127
181
  if (this.broker) {
@@ -165,11 +219,19 @@ var SyncClient = class {
165
219
  resolveDevice(deviceId) {
166
220
  if (deviceId) return deviceId;
167
221
  const connected = this.connectedDevices();
168
- if (connected.length === 1) return connected[0].deviceId;
169
222
  if (connected.length === 0) {
170
223
  throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.");
171
224
  }
172
- const list = connected.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
225
+ if (connected.length === 1) return connected[0].deviceId;
226
+ const usable = connected.filter((d) => d.tools.length > 0);
227
+ if (usable.length === 1) return usable[0].deviceId;
228
+ if (usable.length === 0) {
229
+ const names = connected.map((d) => d.deviceName).join(", ");
230
+ throw new Error(
231
+ `Connected devices (${names}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`
232
+ );
233
+ }
234
+ const list = usable.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
173
235
  throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${list}`);
174
236
  }
175
237
  /**
@@ -194,6 +256,136 @@ var SyncClient = class {
194
256
  return target;
195
257
  }
196
258
  // ----------------------------------------------------------------
259
+ // Device liveness
260
+ // ----------------------------------------------------------------
261
+ healthOf(deviceId) {
262
+ let h = this.health.get(deviceId);
263
+ if (!h) {
264
+ h = { consecutiveTimeouts: 0, lastOkAt: 0, lastFullBudgetAt: 0 };
265
+ this.health.set(deviceId, h);
266
+ }
267
+ return h;
268
+ }
269
+ /** The device answered — it's alive. */
270
+ noteOk(deviceId) {
271
+ const h = this.healthOf(deviceId);
272
+ h.consecutiveTimeouts = 0;
273
+ h.lastOkAt = Date.now();
274
+ }
275
+ /**
276
+ * The device didn't answer. A crashed app never answers again, so after the
277
+ * first miss we stop paying the full timeout on every subsequent call.
278
+ */
279
+ noteTimeout(deviceId) {
280
+ this.healthOf(deviceId).consecutiveTimeouts += 1;
281
+ }
282
+ /** True once a device has missed a call and hasn't answered since. */
283
+ isUnresponsive(deviceId) {
284
+ return this.healthOf(deviceId).consecutiveTimeouts > 0;
285
+ }
286
+ /**
287
+ * Full timeout normally; a short one for a device already not answering.
288
+ *
289
+ * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
290
+ * full budget again. Without it the flag is self-sustaining — a device whose
291
+ * work legitimately takes longer than the short budget (a big snapshot, a
292
+ * slow simulator) can never answer, so it can never clear the flag, and every
293
+ * later reply is a confident "most likely CRASHED" about a healthy app.
294
+ */
295
+ timeoutFor(deviceId) {
296
+ if (!this.isUnresponsive(deviceId)) return this.actionTimeoutMs;
297
+ const h = this.healthOf(deviceId);
298
+ if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
299
+ h.lastFullBudgetAt = Date.now();
300
+ return this.actionTimeoutMs;
301
+ }
302
+ return UNRESPONSIVE_ACTION_TIMEOUT_MS;
303
+ }
304
+ /**
305
+ * Plain-language diagnosis for a device that isn't answering, including the
306
+ * app's last words if the console tool got a crash flush out in time.
307
+ *
308
+ * A silent timeout is the worst possible answer here: the app is on a redbox,
309
+ * the reason is already captured, and without this the caller reads "timed
310
+ * out" and keeps retrying the same dead device.
311
+ */
312
+ unresponsiveNote(deviceId) {
313
+ const h = this.healthOf(deviceId);
314
+ const since = h.lastOkAt ? Math.round((Date.now() - h.lastOkAt) / 1e3) : null;
315
+ const crashed = h.consecutiveTimeouts >= 2 || h.lastOkAt === 0 || Date.now() - h.lastOkAt > UNRESPONSIVE_AFTER_MS;
316
+ const lines = [
317
+ `The app on "${deviceId}" is not responding (${h.consecutiveTimeouts} call${h.consecutiveTimeouts === 1 ? "" : "s"} unanswered${since != null ? `, last answered ${since}s ago` : ""}).`,
318
+ 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."
319
+ ];
320
+ const fatal = this.lastFatalFor(deviceId);
321
+ if (fatal) lines.push(`Last captured error: ${fatal}`);
322
+ lines.push(
323
+ 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."
324
+ );
325
+ return lines.join(" ");
326
+ }
327
+ /**
328
+ * The newest error line the console tool synced for this device, if any.
329
+ * Reads the cached console snapshot rather than the device (which, by the
330
+ * time this matters, is not answering).
331
+ */
332
+ lastFatalFor(deviceId) {
333
+ const snap = this.snapshots.get(snapKey(deviceId, "console"));
334
+ const entries = snap?.entries;
335
+ if (!Array.isArray(entries)) return null;
336
+ for (let i = entries.length - 1; i >= 0; i--) {
337
+ const e = entries[i];
338
+ if (e?.fatal || e?.level === "error") {
339
+ const msg = (e.message ?? "").split("\n")[0];
340
+ if (msg) return msg.length > 300 ? `${msg.slice(0, 300)}\u2026` : msg;
341
+ }
342
+ }
343
+ return null;
344
+ }
345
+ /** Age of the cached snapshot for a tool, in ms (null = never received). */
346
+ snapshotAgeMs(toolId, deviceId) {
347
+ const at = this.snapshotAt.get(snapKey(deviceId, toolId));
348
+ return at ? Date.now() - at : null;
349
+ }
350
+ /**
351
+ * Remember where a device's bundle came from, reported by core's `app`
352
+ * adapter while the app is healthy. It is the only way to reload an app that
353
+ * has crashed: the in-app reload needs the React tree that the crash
354
+ * destroyed, but the dev server's reload is handled natively.
355
+ */
356
+ noteDevServerOrigin(deviceId, origin) {
357
+ if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
358
+ this.devServerOrigins.set(deviceId, origin);
359
+ }
360
+ }
361
+ /**
362
+ * Ask a freshly-announced device where its bundle came from, once.
363
+ *
364
+ * Deliberately eager: this is the piece of information that can ONLY be
365
+ * collected while the app is healthy, and is only ever needed after it has
366
+ * stopped answering. One cheap round-trip per device buys the ability to
367
+ * recover a crashed app instead of telling the caller to go do it by hand.
368
+ */
369
+ async learnDevServerOrigin(deviceId) {
370
+ if (this.devServerOrigins.has(deviceId) || this.originProbed.has(deviceId)) return;
371
+ this.originProbed.add(deviceId);
372
+ const hasApp = this.capabilities.get(deviceId)?.tools?.some((t) => t.toolId === "app");
373
+ if (!hasApp) return;
374
+ try {
375
+ const { payload } = await this.getSnapshot("app", deviceId, { timeoutMs: 4e3, silent: true });
376
+ this.noteDevServerOrigin(deviceId, payload?.devServerOrigin);
377
+ } catch {
378
+ }
379
+ }
380
+ /** The dev-server origin last reported by this device, if any. */
381
+ devServerOrigin(deviceId) {
382
+ const known = this.devServerOrigins.get(deviceId);
383
+ if (known) return known;
384
+ const snap = this.snapshots.get(snapKey(deviceId, "app"));
385
+ if (typeof snap?.devServerOrigin === "string") return snap.devServerOrigin;
386
+ return null;
387
+ }
388
+ // ----------------------------------------------------------------
197
389
  // Watch + snapshots
198
390
  // ----------------------------------------------------------------
199
391
  /**
@@ -264,13 +456,36 @@ var SyncClient = class {
264
456
  const target = this.resolveDevice(deviceId);
265
457
  const key = snapKey(target, toolId);
266
458
  this.ensureWatching(toolId, target);
267
- const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
459
+ let budget = opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
460
+ if (opts.timeoutMs === void 0 && this.isUnresponsive(target)) {
461
+ const h = this.healthOf(target);
462
+ if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
463
+ h.lastFullBudgetAt = Date.now();
464
+ } else {
465
+ budget = UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS;
466
+ }
467
+ }
468
+ const next = this.waitForSnapshot(key, budget);
268
469
  this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
269
470
  const payload = await next;
270
- if (payload !== void 0) return { deviceId: target, payload };
471
+ if (payload !== void 0) {
472
+ if (!opts.silent) this.noteOk(target);
473
+ return { deviceId: target, payload };
474
+ }
475
+ if (!opts.silent) this.noteTimeout(target);
271
476
  const cached = this.snapshots.get(key);
272
- if (cached !== void 0) return { deviceId: target, payload: cached };
273
- throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
477
+ if (cached !== void 0) {
478
+ return { deviceId: target, payload: cached, stale: this.staleNote(toolId, target) };
479
+ }
480
+ throw new Error(
481
+ `Timed out waiting for "${toolId}" state from ${target} (is the tool active?). ${this.unresponsiveNote(target)}`
482
+ );
483
+ }
484
+ /** Header line for data served from cache because the device went quiet. */
485
+ staleNote(toolId, deviceId) {
486
+ const age = this.snapshotAgeMs(toolId, deviceId);
487
+ const ageText = age == null ? "an earlier sync" : `${Math.round(age / 1e3)}s ago`;
488
+ return `\u26A0\uFE0F Served from the last sync (${ageText}) \u2014 ${this.unresponsiveNote(deviceId)}`;
274
489
  }
275
490
  waitForSnapshot(key, timeoutMs) {
276
491
  return new Promise((resolve) => {
@@ -299,11 +514,16 @@ var SyncClient = class {
299
514
  const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
300
515
  if (!this.socket) throw new Error("SyncClient is not connected");
301
516
  const requestId = randomUUID();
302
- const timeoutMs = opts.timeoutMs ?? this.actionTimeoutMs;
517
+ const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target);
303
518
  const result = await new Promise((resolve, reject) => {
304
519
  const timer = setTimeout(() => {
305
520
  this.pendingActions.delete(requestId);
306
- reject(new Error(`Action "${toolId}.${action}" timed out after ${timeoutMs}ms`));
521
+ if (target !== ALL_DEVICES && !opts.silent) this.noteTimeout(target);
522
+ reject(
523
+ new Error(
524
+ `Action "${toolId}.${action}" got no answer in ${timeoutMs}ms. ` + (target === ALL_DEVICES ? "No device responded." : this.unresponsiveNote(target))
525
+ )
526
+ );
307
527
  }, timeoutMs);
308
528
  this.pendingActions.set(requestId, { resolve, reject, timer });
309
529
  this.socket.emit("devtool-action", {
@@ -314,6 +534,7 @@ var SyncClient = class {
314
534
  params: params ?? {}
315
535
  });
316
536
  });
537
+ if (target !== ALL_DEVICES && !opts.silent) this.noteOk(target);
317
538
  if (!result.ok) {
318
539
  throw new Error(result.error || `Action "${toolId}.${action}" failed on device`);
319
540
  }
@@ -344,7 +565,7 @@ async function waitForBroker(url, attempts = 20) {
344
565
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
345
566
 
346
567
  // src/version.ts
347
- var VERSION = "7.0.20" ? "7.0.20" : "0.0.0-dev";
568
+ var VERSION = "7.0.22" ? "7.0.22" : "0.0.0-dev";
348
569
  var PACKAGE_NAME = "@buoy-gg/mcp";
349
570
 
350
571
  // src/update/checkForUpdate.ts
@@ -649,6 +870,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
649
870
  deltaUiFps: null,
650
871
  deltaJsFps: null,
651
872
  renderCommits: num(median?.renderCommits),
873
+ renderWasted: median?.renderWasted,
652
874
  topRenderers: median?.topRenderers ?? []
653
875
  };
654
876
  return { ...base, flags: failed ? [] : computeFlags(base) };
@@ -739,13 +961,18 @@ function formatRankingMarkdown(ranking) {
739
961
  );
740
962
  }
741
963
  const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
742
- const top = r.topRenderers.map((t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms)`).join(", ");
964
+ const top = r.topRenderers.map(
965
+ (t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms${t.cause ? `, ${t.cause}` : ""})`
966
+ ).join(", ");
743
967
  const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
744
- return `- **${r.caseName}** \u2014 ${[commits, top].filter(Boolean).join(": ")}`;
968
+ const wasted = r.renderWasted != null ? `${r.renderWasted} wasted` : "";
969
+ return `- **${r.caseName}** \u2014 ${[commits, wasted, top].filter(Boolean).join(": ")}`;
745
970
  });
746
971
  if (renderLines.length) {
747
972
  lines.push("");
748
- lines.push("**Renders (median run)** \u2014 heaviest components by render self-time:");
973
+ lines.push(
974
+ "**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."
975
+ );
749
976
  lines.push(...renderLines);
750
977
  }
751
978
  lines.push("");
@@ -768,7 +995,20 @@ function compareReportStats(a, b) {
768
995
  cpuUsage: ch(a.stats.cpuUsage, b.stats.cpuUsage),
769
996
  memoryUsage: ch(a.stats.memoryUsage, b.stats.memoryUsage),
770
997
  jsJankFrames: { a: a.stats.jsJankFrames, b: b.stats.jsJankFrames, delta: b.stats.jsJankFrames - a.stats.jsJankFrames },
771
- uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames }
998
+ uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames },
999
+ ...compareRenders(a.renders, b.renders)
1000
+ };
1001
+ }
1002
+ function compareRenders(a, b) {
1003
+ if (!a && !b) return null;
1004
+ const n = (v) => v ?? 0;
1005
+ return {
1006
+ renders: {
1007
+ total: { a: n(a?.totalRenders), b: n(b?.totalRenders), delta: n(b?.totalRenders) - n(a?.totalRenders) },
1008
+ commits: { a: n(a?.totalCommits), b: n(b?.totalCommits), delta: n(b?.totalCommits) - n(a?.totalCommits) },
1009
+ wasted: { a: n(a?.wastedRenders), b: n(b?.wastedRenders), delta: n(b?.wastedRenders) - n(a?.wastedRenders) },
1010
+ causes: { a: a?.causes ?? {}, b: b?.causes ?? {} }
1011
+ }
772
1012
  };
773
1013
  }
774
1014
  function round(n, d) {
@@ -1024,7 +1264,7 @@ ${JSON.stringify(summary, null, 2)}
1024
1264
  async ({ deviceId, maxWaitMs }) => {
1025
1265
  const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
1026
1266
  `);
1027
- const cap = maxWaitMs ?? 6e5;
1267
+ const cap3 = maxWaitMs ?? 6e5;
1028
1268
  try {
1029
1269
  const target = sync.requireProDevice(deviceId);
1030
1270
  const start = Date.now();
@@ -1032,7 +1272,7 @@ ${JSON.stringify(summary, null, 2)}
1032
1272
  let idleStreak = 0;
1033
1273
  let lastCount = -1;
1034
1274
  let resolved = null;
1035
- while (Date.now() - start < cap) {
1275
+ while (Date.now() - start < cap3) {
1036
1276
  let snap2;
1037
1277
  try {
1038
1278
  snap2 = await readPerfSnapshot(sync, target);
@@ -1117,7 +1357,7 @@ ${JSON.stringify(ranking, null, 2)}
1117
1357
  server.registerTool(
1118
1358
  "compare_reports",
1119
1359
  {
1120
- description: "Compare two saved benchmark runs head-to-head (avg + p95 for FPS/CPU/memory, jank, with deltas).",
1360
+ 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.",
1121
1361
  inputSchema: {
1122
1362
  reportIdA: z2.string().describe("First (baseline) report id."),
1123
1363
  reportIdB: z2.string().describe("Second report id to compare against the baseline."),
@@ -1135,8 +1375,8 @@ ${JSON.stringify(ranking, null, 2)}
1135
1375
  if (!b) return fail2(`Report "${reportIdB}" not found.`);
1136
1376
  return json2(
1137
1377
  compareReportStats(
1138
- { name: a.metadata.name, stats: a.stats },
1139
- { name: b.metadata.name, stats: b.stats }
1378
+ { name: a.metadata.name, stats: a.stats, renders: a.renders },
1379
+ { name: b.metadata.name, stats: b.stats, renders: b.renders }
1140
1380
  )
1141
1381
  );
1142
1382
  } catch (e) {
@@ -1254,7 +1494,7 @@ function registerEventsTools(server, sync) {
1254
1494
  server.registerTool(
1255
1495
  "get_events",
1256
1496
  {
1257
- description: "The UNIVERSAL, token-bounded activity reader for what's happening in the app across ALL Buoy tools. The events tool aggregates every source \u2014 network requests, redux/zustand/jotai actions, react-query query/mutation updates, storage writes, route changes, renders \u2014 so filter to one with `sources`: ['network'] lists HTTP requests, ['redux'] dispatched actions, ['react-query'] query updates, ['storage'] writes, ['route'] navigation. Returns a compact one-line summary per event (source \xB7 status \xB7 title \xB7 timing) by DEFAULT \u2014 not the heavy raw payloads \u2014 so it's cheap. Re-call with includeData:true, a bigger limit, or specific sources only when you need detail. PREFER this over get_snapshot (and over reading source code) for any 'what happened' / 'show me the requests/actions' question; get_snapshot is the raw heavy current-state escape hatch.",
1497
+ description: "The UNIVERSAL, token-bounded activity reader for what's happening in the app across ALL Buoy tools. Check this timeline BEFORE driving the UI to reproduce a bug \u2014 the failure is usually already recorded here. The events tool aggregates every source \u2014 network requests, redux/zustand/jotai actions, react-query query/mutation updates, storage writes, route changes, renders \u2014 so filter to one with `sources`: ['network'] lists HTTP requests, ['redux'] dispatched actions, ['react-query'] query updates, ['storage'] writes, ['route'] navigation. Returns a compact one-line summary per event (source \xB7 status \xB7 title \xB7 timing) by DEFAULT \u2014 not the heavy raw payloads \u2014 so it's cheap. Re-call with includeData:true, a bigger limit, or specific sources only when you need detail. PREFER this over get_snapshot (and over reading source code) for any 'what happened' / 'show me the requests/actions' question; get_snapshot is the raw heavy current-state escape hatch.",
1258
1498
  inputSchema: {
1259
1499
  limit: z3.number().optional().describe("Most-recent N events to include. Default 25."),
1260
1500
  sources: z3.array(z3.string()).optional().describe('Only these sources. Coarse names accepted: "network", "redux", "react-query", "storage", "route", "render", "zustand", "jotai". Omit for all.'),
@@ -1649,7 +1889,7 @@ function registerStorageTools(server, sync) {
1649
1889
  server.registerTool(
1650
1890
  "get_storage",
1651
1891
  {
1652
- description: "Read the app's storage keys across ALL backends: AsyncStorage, MMKV (per instance), and Expo SecureStore. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1892
+ description: "Read the app's storage keys across ALL backends: AsyncStorage, MMKV (per instance), and Expo SecureStore. Data wrong or missing after an app restart \u2192 read the persisted values here FIRST \u2014 the bug is usually a stale, missing, or malformed stored value, and reading it beats hunting for it in code. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out.",
1653
1893
  inputSchema: {
1654
1894
  pattern: z8.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1655
1895
  includeValues: z8.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
@@ -1662,13 +1902,13 @@ function registerStorageTools(server, sync) {
1662
1902
  const target = sync.requireProDevice(args.deviceId);
1663
1903
  const pattern = args.pattern?.toLowerCase();
1664
1904
  const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1665
- const matches = (k) => !pattern || k.toLowerCase().includes(pattern);
1905
+ const matches2 = (k) => !pattern || k.toLowerCase().includes(pattern);
1666
1906
  const sections = [];
1667
1907
  const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1668
1908
  if (!Array.isArray(allKeys)) {
1669
1909
  return fail8("No storage result from the device. The storage tool may be inactive.");
1670
1910
  }
1671
- const asyncKeys = allKeys.filter(matches).slice(0, limit);
1911
+ const asyncKeys = allKeys.filter(matches2).slice(0, limit);
1672
1912
  if (asyncKeys.length > 0) {
1673
1913
  let lines;
1674
1914
  if (args.includeValues) {
@@ -1683,7 +1923,7 @@ ${lines.join("\n")}`);
1683
1923
  }
1684
1924
  const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
1685
1925
  for (const inst of mmkv ?? []) {
1686
- const entries = inst.entries.filter((e) => matches(e.key)).slice(0, limit);
1926
+ const entries = inst.entries.filter((e) => matches2(e.key)).slice(0, limit);
1687
1927
  if (entries.length === 0) continue;
1688
1928
  const lines = entries.map(
1689
1929
  (e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate(e.value)}` : `- \`${e.key}\``
@@ -1693,7 +1933,7 @@ ${lines.join("\n")}`);
1693
1933
  ${lines.join("\n")}`);
1694
1934
  }
1695
1935
  const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
1696
- const secureKeys = (secure ?? []).filter((d) => matches(d.key)).slice(0, limit);
1936
+ const secureKeys = (secure ?? []).filter((d) => matches2(d.key)).slice(0, limit);
1697
1937
  if (secureKeys.length > 0) {
1698
1938
  const lines = await Promise.all(
1699
1939
  secureKeys.map(async (d) => {
@@ -1850,7 +2090,7 @@ function registerConsoleTools(server, sync) {
1850
2090
  server.registerTool(
1851
2091
  "get_console",
1852
2092
  {
1853
- description: "Read the app's captured console logs (console.log/warn/error/\u2026) as a compact tail \u2014 time \xB7 level \xB7 message. Token-cheap (uses each entry's pre-rendered message, not raw args). Filter by minimum level (e.g. level:'warning' for warnings+errors) or a message substring. Use for 'what's logging / any console errors'. For full args/stack of an entry, fall back to get_snapshot('console').",
2093
+ description: "Read the app's captured console logs (console.log/warn/error/\u2026) as a compact tail \u2014 time \xB7 level \xB7 message. App crashing or erroring \u2192 read this FIRST: it captures crashes and errors including FATAL uncaught JS errors (tagged [FATAL], hooked from RN's global error handler), so a dead app's last words are here, not in the code. Token-cheap (uses each entry's pre-rendered message, not raw args). Filter by minimum level (e.g. level:'warning' for warnings+errors) or a message substring. For full args/stack of an entry, fall back to get_snapshot('console').",
1854
2094
  inputSchema: {
1855
2095
  level: z10.enum(["verbose", "info", "warning", "error"]).optional().describe("Minimum level to include (severity threshold). e.g. 'warning' = warnings + errors. Default all."),
1856
2096
  limit: z10.number().optional().describe("Most-recent N entries. Default 50."),
@@ -1861,7 +2101,10 @@ function registerConsoleTools(server, sync) {
1861
2101
  async (args) => {
1862
2102
  try {
1863
2103
  const target = sync.requireProDevice(args.deviceId);
1864
- const { payload } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
2104
+ const { payload, stale } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
2105
+ const staleHeader = stale ? `${stale}
2106
+
2107
+ ` : "";
1865
2108
  const p = Array.isArray(payload) ? { entries: payload } : payload ?? {};
1866
2109
  const all = Array.isArray(p.entries) ? p.entries : [];
1867
2110
  const minSev = args.level ? SEVERITY[args.level] : 0;
@@ -1873,12 +2116,12 @@ function registerConsoleTools(server, sync) {
1873
2116
  const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 50;
1874
2117
  const shown = sorted.slice(-limit);
1875
2118
  if (shown.length === 0) {
1876
- return text9(`No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
2119
+ return text9(`${staleHeader}No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
1877
2120
  }
1878
2121
  const lines = shown.map((e) => `${hhmmss(e.timestamp)} [${TAG[e.level] ?? e.level}] ${e.message}`);
1879
2122
  const levelNote = args.level ? `, level \u2265 ${args.level}` : "";
1880
2123
  const header = `**Console \u2014 ${all.length} entries (showing ${shown.length}${levelNote}):**`;
1881
- return text9(`${header}
2124
+ return text9(`${staleHeader}${header}
1882
2125
 
1883
2126
  \`\`\`
1884
2127
  ${lines.join("\n")}
@@ -2068,7 +2311,7 @@ function registerScreenshotTools(server, sync) {
2068
2311
 
2069
2312
  // src/server.ts
2070
2313
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2071
- import { z as z19 } from "zod";
2314
+ import { z as z22 } from "zod";
2072
2315
 
2073
2316
  // src/tools/packs/network.ts
2074
2317
  import { z as z12 } from "zod";
@@ -2192,7 +2435,7 @@ function registerNetworkTools(server, sync) {
2192
2435
  server.registerTool(
2193
2436
  "get_network_requests",
2194
2437
  {
2195
- description: "List captured HTTP requests WITH their request ids, plus which ones are pinned/saved. Use this (not get_events) when you need to ACT on a request \u2014 every network action is keyed by the id, and get_events does not emit ids. Compact by default: one line per request (id \xB7 status \xB7 method \xB7 path \xB7 timing \xB7 size). Set flagged:'pinned'|'saved'|'any' to read the developer's KEPT requests \u2014 those are snapshots that outlive Clear, the 500-request cap and app restarts, so treat a pinned request as 'a human flagged this as the problem'. Filter with status:'errors' to go straight to failures, or `pattern` to match a URL substring. includeBodies:true adds truncated request/response payloads (heavier).",
2438
+ description: "List captured HTTP requests WITH their request ids, plus which ones are pinned/saved. API data wrong or calls failing \u2192 read the actual request+response bodies here instead of guessing from code; capture starts at app launch, so boot-time requests (session bootstrap, first queries) are included. Use this (not get_events) when you need to ACT on a request \u2014 every network action is keyed by the id, and get_events does not emit ids. Compact by default: one line per request (id \xB7 status \xB7 method \xB7 path \xB7 timing \xB7 size). Set flagged:'pinned'|'saved'|'any' to read the developer's KEPT requests \u2014 those are snapshots that outlive Clear, the 500-request cap and app restarts, so treat a pinned request as 'a human flagged this as the problem'. Filter with status:'errors' to go straight to failures, or `pattern` to match a URL substring. includeBodies:true adds truncated request/response payloads (heavier).",
2196
2439
  inputSchema: {
2197
2440
  limit: z12.number().optional().describe("Most-recent N requests. Default 25."),
2198
2441
  pattern: z12.string().optional().describe("Only requests whose URL contains this substring (case-insensitive)."),
@@ -2567,13 +2810,19 @@ function kb(bytes) {
2567
2810
  return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
2568
2811
  }
2569
2812
  function formatOutcome(outcome) {
2570
- const lines = Object.entries(outcome.results).map(([sid, r]) => {
2571
- const skips = r.skipped.slice(0, 3).map((s) => `
2813
+ const lines = Object.entries(outcome.results).map(([sid, r2]) => {
2814
+ const skips = r2.skipped.slice(0, 3).map((s) => `
2572
2815
  \xB7 ${s.id}: ${s.reason}`).join("");
2573
- const warns = r.warnings.slice(0, 3).map((w) => `
2816
+ const warns = r2.warnings.slice(0, 3).map((w) => `
2574
2817
  \u26A0 ${w}`).join("");
2575
- return ` ${r.ok ? "\u2713" : "\u2715"} ${sid} \u2014 applied ${r.applied}${r.skipped.length ? `, skipped ${r.skipped.length}` : ""}${skips}${warns}`;
2818
+ return ` ${r2.ok ? "\u2713" : "\u2715"} ${sid} \u2014 applied ${r2.applied}${r2.skipped.length ? `, skipped ${r2.skipped.length}` : ""}${skips}${warns}`;
2576
2819
  });
2820
+ const r = outcome.route;
2821
+ if (r) {
2822
+ lines.push(
2823
+ 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}`
2824
+ );
2825
+ }
2577
2826
  return `Restored "${outcome.snapshotName}" (${outcome.mode}${outcome.willReload ? ", app reloading" : ""}):
2578
2827
  ${lines.join("\n")}`;
2579
2828
  }
@@ -2581,7 +2830,7 @@ function registerTimeMachineTools(server, sync) {
2581
2830
  server.registerTool(
2582
2831
  "get_time_machine",
2583
2832
  {
2584
- 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.",
2833
+ 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.",
2585
2834
  inputSchema: {
2586
2835
  deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected).")
2587
2836
  }
@@ -2594,13 +2843,19 @@ function registerTimeMachineTools(server, sync) {
2594
2843
  const snapshots = p.snapshots ?? [];
2595
2844
  const providers = p.providers ?? [];
2596
2845
  const providerLines = providers.map((pr) => {
2597
- const cap = pr.canCapture ? pr.canRestore ? "capture+restore" : "capture only" : "unavailable";
2598
- return `- ${pr.label} (${pr.id}): ${cap}${pr.detail ? ` \u2014 ${pr.detail}` : ""}`;
2846
+ const cap3 = pr.canCapture ? pr.canRestore ? "capture+restore" : "capture only" : "unavailable";
2847
+ return `- ${pr.label} (${pr.id}): ${cap3}${pr.detail ? ` \u2014 ${pr.detail}` : ""}`;
2599
2848
  });
2849
+ if (p.route) {
2850
+ providerLines.push(
2851
+ 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"
2852
+ );
2853
+ }
2600
2854
  const snapLines = snapshots.map((s) => {
2601
2855
  const warnings = Object.values(s.sources).reduce((n, src) => n + src.warnings.length, 0);
2602
2856
  const desc = s.baseline ? "baseline \u2014 restore wipes to fresh install (always reloads)" : `${kb(s.sizeBytes)} \xB7 ${Object.keys(s.sources).join(", ")}`;
2603
- return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
2857
+ const route = s.route ? ` \xB7 route ${s.route.href}` : "";
2858
+ return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${route}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
2604
2859
  });
2605
2860
  const parts = [
2606
2861
  `**Time Machine \u2014 ${snapshots.length} snapshot(s):**`,
@@ -2619,13 +2874,14 @@ ${formatOutcome(p.lastOutcome)}`);
2619
2874
  server.registerTool(
2620
2875
  "time_machine_action",
2621
2876
  {
2622
- 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.",
2877
+ 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.",
2623
2878
  inputSchema: {
2624
2879
  action: z13.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
2625
2880
  id: z13.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
2626
2881
  name: z13.string().optional().describe("Snapshot name (for capture and rename)."),
2627
2882
  mode: z13.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
2628
2883
  excludeKeys: z13.array(z13.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").'),
2884
+ restoreRoute: z13.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."),
2629
2885
  sourceIds: z13.array(z13.string()).optional().describe("Capture/restore only these source ids (default all)."),
2630
2886
  deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected).")
2631
2887
  }
@@ -2674,7 +2930,8 @@ ${lines.join("\n")}`);
2674
2930
  const outcome = await call("restore", {
2675
2931
  id: args.id,
2676
2932
  mode: args.mode ?? "live",
2677
- excludeKeys: args.excludeKeys
2933
+ excludeKeys: args.excludeKeys,
2934
+ restoreRoute: args.restoreRoute
2678
2935
  });
2679
2936
  return text11(formatOutcome(outcome));
2680
2937
  }
@@ -2718,11 +2975,14 @@ function formatElement(e) {
2718
2975
  if (e.nativeTag != null) line += ` #${e.nativeTag}`;
2719
2976
  return line;
2720
2977
  }
2978
+ var tapCount = 0;
2979
+ var FLOW_TIP_EVERY = 3;
2980
+ 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.";
2721
2981
  function registerInteractTools(server, sync) {
2722
2982
  server.registerTool(
2723
2983
  "describe_screen",
2724
2984
  {
2725
- 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.",
2985
+ 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.",
2726
2986
  inputSchema: {
2727
2987
  interactiveOnly: z14.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
2728
2988
  deviceId: z14.string().optional().describe("Target device id (auto-selected if one is connected).")
@@ -2751,7 +3011,7 @@ ${elements.map(formatElement).join("\n")}`);
2751
3011
  server.registerTool(
2752
3012
  "tap_element",
2753
3013
  {
2754
- 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.",
3014
+ 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.",
2755
3015
  inputSchema: {
2756
3016
  nativeTag: z14.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
2757
3017
  testID: z14.string().optional().describe("Exact testID of the element."),
@@ -2783,13 +3043,15 @@ ${elements.map(formatElement).join("\n")}`);
2783
3043
  },
2784
3044
  target
2785
3045
  );
3046
+ tapCount += 1;
3047
+ const flowTip = tapCount >= FLOW_TIP_EVERY && tapCount % FLOW_TIP_EVERY === 0 ? FLOW_TIP : "";
2786
3048
  if (res?.tapped && res.matched) {
2787
3049
  const m = res.matched;
2788
3050
  const label = m.text ?? m.label ?? m.name;
2789
3051
  const id = m.testID ? ` [testID=${m.testID}]` : m.nativeTag != null ? ` #${m.nativeTag}` : "";
2790
3052
  const scrolledNote = res.scrolled ? " (scrolled into view)" : "";
2791
3053
  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}`;
2792
- return text12(`${verb}${scrolledNote}.`);
3054
+ return text12(`${verb}${scrolledNote}.${flowTip}`);
2793
3055
  }
2794
3056
  let msg = res?.reason ?? "Tap failed.";
2795
3057
  if (res?.candidates?.length) {
@@ -2877,7 +3139,7 @@ function registerJsThreadTools(server, sync) {
2877
3139
  server.registerTool(
2878
3140
  "get_js_thread_top",
2879
3141
  {
2880
- 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.",
3142
+ 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.',
2881
3143
  inputSchema: {
2882
3144
  deviceId: z15.string().optional().describe("Target device id (auto-selected if one is connected)."),
2883
3145
  durationMs: z15.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
@@ -3457,6 +3719,62 @@ ${JSON.stringify(slim)}
3457
3719
 
3458
3720
  // src/tools/packs/reload.ts
3459
3721
  import { z as z18 } from "zod";
3722
+
3723
+ // src/capture/devServerReload.ts
3724
+ import { WebSocket } from "ws";
3725
+ var CONNECT_TIMEOUT_MS = 3e3;
3726
+ function messageSocketUrl(origin) {
3727
+ return `${origin.replace(/^http/, "ws").replace(/\/+$/, "")}/message`;
3728
+ }
3729
+ function reloadViaDevServer(origin) {
3730
+ return new Promise((resolve) => {
3731
+ let settled = false;
3732
+ const done = (result) => {
3733
+ if (settled) return;
3734
+ settled = true;
3735
+ resolve(result);
3736
+ };
3737
+ let socket;
3738
+ try {
3739
+ socket = new WebSocket(messageSocketUrl(origin));
3740
+ } catch (e) {
3741
+ done({ ok: false, error: e.message });
3742
+ return;
3743
+ }
3744
+ const timer = setTimeout(() => {
3745
+ try {
3746
+ socket.close();
3747
+ } catch {
3748
+ }
3749
+ done({ ok: false, error: `dev server at ${origin} did not accept a connection` });
3750
+ }, CONNECT_TIMEOUT_MS);
3751
+ let sent = false;
3752
+ socket.on("open", () => {
3753
+ try {
3754
+ socket.send(JSON.stringify({ version: 2, method: "reload" }));
3755
+ sent = true;
3756
+ clearTimeout(timer);
3757
+ setTimeout(() => {
3758
+ try {
3759
+ socket.close();
3760
+ } catch {
3761
+ }
3762
+ done({ ok: true });
3763
+ }, 250);
3764
+ } catch (e) {
3765
+ clearTimeout(timer);
3766
+ done({ ok: false, error: e.message });
3767
+ }
3768
+ });
3769
+ socket.on("error", (e) => {
3770
+ clearTimeout(timer);
3771
+ if (sent) return;
3772
+ done({ ok: false, error: e.message });
3773
+ });
3774
+ });
3775
+ }
3776
+
3777
+ // src/tools/packs/reload.ts
3460
3778
  var APP_TOOL_ID = "app";
3461
3779
  var RELOAD_ACTION = "reloadApp";
3462
3780
  var DEFAULT_WAIT_MS = 3e4;
@@ -3469,7 +3787,11 @@ var errMsg18 = (e) => e instanceof Error ? e.message : String(e);
3469
3787
  var delay3 = (ms) => new Promise((r) => setTimeout(r, ms));
3470
3788
  async function ping(sync, deviceId, timeoutMs) {
3471
3789
  try {
3472
- const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, { timeoutMs });
3790
+ const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, {
3791
+ timeoutMs,
3792
+ silent: true
3793
+ });
3794
+ sync.noteDevServerOrigin(deviceId, pong?.devServerOrigin);
3473
3795
  return typeof pong?.startedAt === "number" ? pong.startedAt : null;
3474
3796
  } catch {
3475
3797
  return null;
@@ -3498,7 +3820,7 @@ function registerReloadTools(server, sync) {
3498
3820
  server.registerTool(
3499
3821
  "reload_app",
3500
3822
  {
3501
- 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.",
3823
+ 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.",
3502
3824
  inputSchema: {
3503
3825
  strategy: z18.enum(["auto", "dev-settings", "expo-updates"]).optional().describe(
3504
3826
  "Reload mechanism. 'auto' (default) uses DevSettings in dev builds and expo-updates otherwise. Force one only when debugging the reload itself."
@@ -3513,13 +3835,27 @@ function registerReloadTools(server, sync) {
3513
3835
  const target = sync.requireProDevice(args.deviceId);
3514
3836
  assertReloadSupported(sync, target);
3515
3837
  const previousRealm = args.wait === false ? null : await ping(sync, target, PROBE_TIMEOUT_MS);
3516
- const ack = await sync.callAction(
3517
- APP_TOOL_ID,
3518
- RELOAD_ACTION,
3519
- { strategy: args.strategy ?? "auto" },
3520
- target
3521
- );
3522
- const via = ack?.strategy ? ` via ${ack.strategy}` : "";
3838
+ let ack = null;
3839
+ let recovered = false;
3840
+ try {
3841
+ ack = await sync.callAction(
3842
+ APP_TOOL_ID,
3843
+ RELOAD_ACTION,
3844
+ { strategy: args.strategy ?? "auto" },
3845
+ target
3846
+ );
3847
+ } catch (inAppError) {
3848
+ const origin = sync.devServerOrigin(target);
3849
+ if (!origin) throw inAppError;
3850
+ const res = await reloadViaDevServer(origin);
3851
+ if (!res.ok) {
3852
+ throw new Error(
3853
+ `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.`
3854
+ );
3855
+ }
3856
+ recovered = true;
3857
+ }
3858
+ const via = recovered ? ` via the dev server (the app was unresponsive \u2014 its own reload could not be reached)` : ack?.strategy ? ` via ${ack.strategy}` : "";
3523
3859
  if (args.wait === false) {
3524
3860
  return text16(`Reload requested${via}. The app is restarting \u2014 its in-memory state is gone; re-read anything you cached.`);
3525
3861
  }
@@ -3539,12 +3875,836 @@ function registerReloadTools(server, sync) {
3539
3875
  );
3540
3876
  }
3541
3877
 
3878
+ // src/tools/packs/triage.ts
3879
+ import { z as z19 } from "zod";
3880
+ var text17 = (s) => ({ content: [{ type: "text", text: s }] });
3881
+ var fail19 = (message) => ({
3882
+ isError: true,
3883
+ content: [{ type: "text", text: message }]
3884
+ });
3885
+ var errMsg19 = (e) => e instanceof Error ? e.message : String(e);
3886
+ var PROBE_TIMEOUT_MS2 = 5e3;
3887
+ var MAX_STORAGE_KEYS = 20;
3888
+ var MAX_STORAGE_VALUE = 400;
3889
+ var STORAGE_VALUE_BUDGET = 1600;
3890
+ var MAX_LINE = 160;
3891
+ var cap = (s, n = MAX_LINE) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
3892
+ function hhmmss2(ts) {
3893
+ if (!ts) return "--:--:--";
3894
+ const d = new Date(ts);
3895
+ const p = (n) => String(n).padStart(2, "0");
3896
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
3897
+ }
3898
+ async function otherBackends(sync, target, opts) {
3899
+ const notes = [];
3900
+ try {
3901
+ const mmkv = await sync.callAction("storage", "mmkv.snapshot", {}, target, opts);
3902
+ for (const inst of mmkv ?? []) {
3903
+ const n = inst.entries?.length ?? 0;
3904
+ if (n > 0) notes.push(`MMKV "${inst.id}": ${n} key${n === 1 ? "" : "s"}`);
3905
+ }
3906
+ } catch {
3907
+ }
3908
+ try {
3909
+ const secure = await sync.callAction("storage", "secure.keys", {}, target, opts);
3910
+ if (secure?.length) notes.push(`SecureStore: ${secure.length} key${secure.length === 1 ? "" : "s"}`);
3911
+ } catch {
3912
+ }
3913
+ return notes.length ? `
3914
+ ${notes.join(" \xB7 ")} \u2014 read with get_storage` : "";
3915
+ }
3916
+ async function section(title, run) {
3917
+ try {
3918
+ const body = await run();
3919
+ return body ? `${title}
3920
+ ${body}` : null;
3921
+ } catch (e) {
3922
+ const msg = errMsg19(e);
3923
+ if (/not responding|timed out|got no answer/i.test(msg)) {
3924
+ return `${title}
3925
+ (no answer \u2014 see the note above)`;
3926
+ }
3927
+ return `${title}
3928
+ (unavailable: ${cap(msg, 120)})`;
3929
+ }
3930
+ }
3931
+ function registerTriageTools(server, sync) {
3932
+ server.registerTool(
3933
+ "get_triage",
3934
+ {
3935
+ 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.",
3936
+ inputSchema: {
3937
+ deviceId: z19.string().optional().describe("Target device id (auto-selected if one is connected).")
3938
+ }
3939
+ },
3940
+ async (args) => {
3941
+ try {
3942
+ const target = sync.requireProDevice(args.deviceId);
3943
+ const device = sync.connectedDevices().find((d) => d.deviceId === target);
3944
+ const toolIds = new Set((device?.tools ?? []).map((t) => t.toolId));
3945
+ const head = `**${device?.deviceName ?? target}** (${device?.platform ?? "?"}, id \`${target}\`) \u2014 tools: ${[...toolIds].join(", ") || "none"}`;
3946
+ const opts = { timeoutMs: PROBE_TIMEOUT_MS2 };
3947
+ const [consoleSec, netSec, storeSec, screenSec] = await Promise.all([
3948
+ section("**Console \u2014 recent errors/warnings:**", async () => {
3949
+ if (!toolIds.has("console")) return null;
3950
+ const { payload } = await sync.getSnapshot("console", target, opts);
3951
+ const p = payload;
3952
+ const entries = Array.isArray(p) ? p : p?.entries ?? [];
3953
+ const bad = entries.filter(
3954
+ (e) => e.level === "error" || e.level === "warning"
3955
+ );
3956
+ const shown = bad.slice(-8);
3957
+ if (shown.length === 0) {
3958
+ return `(none \u2014 ${entries.length} entries captured)`;
3959
+ }
3960
+ return shown.map(
3961
+ (e) => `${hhmmss2(e.timestamp)} [${e.level === "error" ? "ERR" : "warn"}] ${cap(
3962
+ (e.message ?? "").split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 3).join(" \u23CE "),
3963
+ 220
3964
+ )}`
3965
+ ).join("\n");
3966
+ }),
3967
+ section("**Network \u2014 recent requests:**", async () => {
3968
+ if (!toolIds.has("network")) return null;
3969
+ const { payload } = await sync.getSnapshot("network", target, opts);
3970
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
3971
+ if (events.length === 0) return "(no requests captured yet)";
3972
+ const shown = events.slice(-8);
3973
+ return shown.map((e) => {
3974
+ const mark = e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705";
3975
+ const where = e.path ?? e.url ?? "?";
3976
+ const status = e.error ?? e.status ?? "pending";
3977
+ return `${mark} ${e.method ?? "GET"} ${cap(String(where), 80)} \u2192 ${status}${e.duration ? ` (${Math.round(e.duration)}ms)` : ""}`;
3978
+ }).join("\n");
3979
+ }),
3980
+ section("**Storage \u2014 persisted values:**", async () => {
3981
+ if (!toolIds.has("storage")) return null;
3982
+ const keys = await sync.callAction(
3983
+ "storage",
3984
+ "async.getAllKeys",
3985
+ {},
3986
+ target,
3987
+ opts
3988
+ );
3989
+ if (!Array.isArray(keys) || keys.length === 0) {
3990
+ return "(no app keys)";
3991
+ }
3992
+ const head2 = keys.slice(0, MAX_STORAGE_KEYS);
3993
+ let pairs = [];
3994
+ try {
3995
+ pairs = await sync.callAction(
3996
+ "storage",
3997
+ "async.multiGet",
3998
+ { keys: head2 },
3999
+ target,
4000
+ opts
4001
+ ) ?? [];
4002
+ } catch {
4003
+ return `${head2.map((k) => `\`${k}\``).join(", ")}
4004
+ _Values: get_storage includeValues:true_`;
4005
+ }
4006
+ const byKey = new Map(pairs);
4007
+ let budget = STORAGE_VALUE_BUDGET;
4008
+ const lines = head2.map((k) => {
4009
+ const v = byKey.get(k);
4010
+ if (v == null) return `- \`${k}\` = (null)`;
4011
+ if (v.length > MAX_STORAGE_VALUE) {
4012
+ return `- \`${k}\` = (${(v.length / 1024).toFixed(1)} KB \u2014 read with get_storage)`;
4013
+ }
4014
+ if (budget <= 0) return `- \`${k}\` = (not shown \u2014 triage budget spent; read with get_storage)`;
4015
+ budget -= v.length;
4016
+ return `- \`${k}\` = ${v}`;
4017
+ });
4018
+ const more = keys.length - head2.length;
4019
+ const others = await otherBackends(sync, target, opts);
4020
+ return `${lines.join("\n")}${more > 0 ? `
4021
+ \u2026+${more} more keys` : ""}${others}`;
4022
+ }),
4023
+ section("**Screen \u2014 what's visible now:**", async () => {
4024
+ if (!toolIds.has("highlight-updates")) return null;
4025
+ const res = await sync.callAction(
4026
+ "highlight-updates",
4027
+ "describeScreen",
4028
+ {},
4029
+ target,
4030
+ opts
4031
+ );
4032
+ const els = (res?.elements ?? []).filter((e) => e.interactive);
4033
+ if (els.length === 0) return "(nothing interactive on screen)";
4034
+ return els.slice(0, 20).map(
4035
+ (e) => `\u2022 ${e.name}${e.text || e.label ? ` "${cap(e.text ?? e.label ?? "", 60)}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`
4036
+ ).join("\n");
4037
+ })
4038
+ ]);
4039
+ const crashed = sync.isUnresponsive(target);
4040
+ const banner = crashed ? `
4041
+
4042
+ \u{1F6D1} ${sync.unresponsiveNote(target)}` : "";
4043
+ const body = [consoleSec, netSec, storeSec, screenSec].filter(Boolean).join("\n\n");
4044
+ return text17(
4045
+ `${head}${banner}
4046
+
4047
+ ${body}
4048
+
4049
+ _Drill in with get_console, get_network_requests (includeBodies:true for response bodies), get_storage (includeValues:true), describe_screen._`
4050
+ );
4051
+ } catch (e) {
4052
+ return fail19(errMsg19(e));
4053
+ }
4054
+ }
4055
+ );
4056
+ }
4057
+
4058
+ // src/tools/packs/flow.ts
4059
+ import { z as z20 } from "zod";
4060
+ var UI_TOOL_ID = "highlight-updates";
4061
+ var APP_TOOL_ID2 = "app";
4062
+ var text18 = (s) => ({ content: [{ type: "text", text: s }] });
4063
+ var fail20 = (message) => ({
4064
+ isError: true,
4065
+ content: [{ type: "text", text: message }]
4066
+ });
4067
+ var errMsg20 = (e) => e instanceof Error ? e.message : String(e);
4068
+ var delay4 = (ms) => new Promise((r) => setTimeout(r, ms));
4069
+ var POLL_MS = 250;
4070
+ var DEFAULT_WAIT_MS2 = 5e3;
4071
+ var ASSERT_SETTLE_MS = 2e3;
4072
+ var TEARDOWN_GRACE_MS2 = 1200;
4073
+ var REALM_PROBE_MS = 1500;
4074
+ var MAX_FLOW_MS = 12e4;
4075
+ function matches(e, needle) {
4076
+ if (e.testID && e.testID === needle) return true;
4077
+ const n = needle.toLowerCase();
4078
+ return [e.testID, e.text, e.label, e.name].filter(Boolean).some((v) => String(v).toLowerCase().includes(n));
4079
+ }
4080
+ function formatElement2(e) {
4081
+ const display = e.text ?? e.label;
4082
+ return `${e.interactive ? "\u2022 " : " "}${e.name}${display ? ` "${display}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`;
4083
+ }
4084
+ var stepSchema = z20.object({
4085
+ tap: z20.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
4086
+ longPress: z20.boolean().optional().describe("Make `tap` a long-press instead."),
4087
+ type: z20.string().optional().describe("Text to type (needs `into`)."),
4088
+ into: z20.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
4089
+ set: z20.union([z20.number(), z20.boolean()]).optional().describe("Set a toggle (true/false) or slider (number) \u2014 pair with `tap` as the target."),
4090
+ reload: z20.boolean().optional().describe("Reload the JS bundle before this step's action."),
4091
+ waitMs: z20.number().min(0).max(3e4).optional().describe("Fixed pause after the action, in ms (max 30000)."),
4092
+ waitFor: z20.string().optional().describe("Wait until an element matching this appears (default 5s)."),
4093
+ waitGone: z20.string().optional().describe("Wait until no element matches this (default 5s)."),
4094
+ expect: z20.string().optional().describe("Assert an element matching this IS on screen (waits up to 2s for it); fails the flow if not."),
4095
+ expectGone: z20.string().optional().describe("Assert nothing matches this (waits up to 2s for it to go); fails the flow if something does."),
4096
+ timeoutMs: z20.number().min(0).max(6e4).optional().describe("Budget for this step's waitFor/waitGone/expect, max 60000. Default 5000 (2000 for expect).")
4097
+ }).describe("One flow step. Order within a step: reload \u2192 tap/type/set \u2192 waitMs \u2192 waitFor/waitGone \u2192 expect.");
4098
+ function registerFlowTools(server, sync) {
4099
+ server.registerTool(
4100
+ "run_flow",
4101
+ {
4102
+ 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.",
4103
+ inputSchema: {
4104
+ steps: z20.array(stepSchema).min(1).describe("Steps to run, in order."),
4105
+ include: z20.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."),
4106
+ continueOnFailure: z20.boolean().optional().describe("Keep going after a failed step instead of stopping. Default false."),
4107
+ deviceId: z20.string().optional().describe("Target device id (auto-selected if one is connected).")
4108
+ }
4109
+ },
4110
+ async (args) => {
4111
+ const started = Date.now();
4112
+ try {
4113
+ const target = sync.requireProDevice(args.deviceId);
4114
+ const include = args.include ?? "all";
4115
+ const describe = async () => {
4116
+ const res = await sync.callAction(UI_TOOL_ID, "describeScreen", {}, target);
4117
+ return res?.elements ?? [];
4118
+ };
4119
+ const realmId = async () => {
4120
+ try {
4121
+ const pong = await sync.callAction(APP_TOOL_ID2, "ping", {}, target, {
4122
+ timeoutMs: REALM_PROBE_MS,
4123
+ silent: true
4124
+ // probes across a reload are expected to fail
4125
+ });
4126
+ return typeof pong?.startedAt === "number" ? pong.startedAt : null;
4127
+ } catch {
4128
+ return null;
4129
+ }
4130
+ };
4131
+ const untilNewRealm = async (previous, budgetMs) => {
4132
+ const t0 = Date.now();
4133
+ while (Date.now() - t0 < budgetMs) {
4134
+ const realm = await realmId();
4135
+ if (realm !== null && realm !== previous) return Date.now() - t0;
4136
+ await delay4(POLL_MS);
4137
+ }
4138
+ return null;
4139
+ };
4140
+ const beforeConsole = /* @__PURE__ */ new Set();
4141
+ const beforeNetwork = /* @__PURE__ */ new Set();
4142
+ const toolIds = new Set(
4143
+ (sync.connectedDevices().find((d) => d.deviceId === target)?.tools ?? []).map((t) => t.toolId)
4144
+ );
4145
+ if (include === "all") {
4146
+ if (toolIds.has("console")) {
4147
+ const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
4148
+ const entries = payload?.entries ?? [];
4149
+ for (const e of entries) beforeConsole.add(e.id);
4150
+ }
4151
+ if (toolIds.has("network")) {
4152
+ const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
4153
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
4154
+ for (const e of events) beforeNetwork.add(e.id);
4155
+ }
4156
+ }
4157
+ const until = async (predicate, budgetMs) => {
4158
+ const t0 = Date.now();
4159
+ for (; ; ) {
4160
+ if (Date.now() - t0 >= budgetMs || Date.now() - started > MAX_FLOW_MS) {
4161
+ return { ok: false, ms: Date.now() - t0 };
4162
+ }
4163
+ let els = [];
4164
+ try {
4165
+ els = await describe();
4166
+ } catch {
4167
+ }
4168
+ if (predicate(els)) return { ok: true, ms: Date.now() - t0 };
4169
+ await delay4(POLL_MS);
4170
+ }
4171
+ };
4172
+ const lines = [];
4173
+ let failedAt = null;
4174
+ let okCount = 0;
4175
+ for (let i = 0; i < args.steps.length; i++) {
4176
+ const step = args.steps[i];
4177
+ const label = `${i + 1}`;
4178
+ const budget = step.timeoutMs ?? DEFAULT_WAIT_MS2;
4179
+ if (Date.now() - started > MAX_FLOW_MS) {
4180
+ lines.push(`${label} \u2717 flow exceeded ${MAX_FLOW_MS / 1e3}s \u2014 stopped here`);
4181
+ failedAt = i;
4182
+ break;
4183
+ }
4184
+ const stepErrors = [];
4185
+ const done = [];
4186
+ const actionable = step.reload || step.tap || step.type !== void 0 || step.waitMs || step.waitFor || step.waitGone || step.expect || step.expectGone;
4187
+ if (!actionable) {
4188
+ 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)" : ""}`);
4189
+ failedAt = failedAt ?? i;
4190
+ if (!args.continueOnFailure) break;
4191
+ continue;
4192
+ }
4193
+ try {
4194
+ if (step.reload) {
4195
+ const previousRealm = await realmId();
4196
+ await sync.callAction(APP_TOOL_ID2, "reloadApp", { strategy: "auto" }, target);
4197
+ await delay4(TEARDOWN_GRACE_MS2);
4198
+ const back = await untilNewRealm(previousRealm, Math.max(budget, 2e4));
4199
+ if (back == null) {
4200
+ stepErrors.push("app did not come back after reload");
4201
+ } else {
4202
+ done.push(`reloaded (${back}ms)`);
4203
+ await until((els) => els.length > 0, 1e4);
4204
+ }
4205
+ }
4206
+ if (step.type !== void 0) {
4207
+ const targetSel = step.into ?? step.tap;
4208
+ if (!targetSel) {
4209
+ stepErrors.push("`type` needs `into` (which input to type into)");
4210
+ } else {
4211
+ const res = await sync.callAction(
4212
+ UI_TOOL_ID,
4213
+ "tapElement",
4214
+ { ...selector(targetSel), text: step.type },
4215
+ target
4216
+ );
4217
+ if (!res?.tapped) {
4218
+ stepErrors.push(res?.reason ?? `could not type into "${targetSel}"`);
4219
+ } else if (res.matched?.via && res.matched.via !== "onChangeText") {
4220
+ stepErrors.push(
4221
+ `"${targetSel}" is not a text input \u2014 it ran ${res.matched.via} instead, and the text was not entered`
4222
+ );
4223
+ } else {
4224
+ done.push(`typed ${JSON.stringify(step.type)} into "${targetSel}"`);
4225
+ }
4226
+ }
4227
+ } else if (step.tap) {
4228
+ const params = { ...selector(step.tap) };
4229
+ if (step.longPress) params.longPress = true;
4230
+ if (step.set !== void 0) params.value = step.set;
4231
+ const res = await sync.callAction(UI_TOOL_ID, "tapElement", params, target);
4232
+ if (!res?.tapped) {
4233
+ stepErrors.push(res?.reason ?? `no element matched "${step.tap}"`);
4234
+ } else {
4235
+ const m = res.matched;
4236
+ const what = m?.text ?? m?.label ?? m?.name ?? step.tap;
4237
+ if (step.set !== void 0 && m?.via && m.via !== "onValueChange") {
4238
+ stepErrors.push(
4239
+ `"${what}" is not a toggle/slider \u2014 it ran ${m.via} instead, so it was not set to ${JSON.stringify(step.set)}`
4240
+ );
4241
+ } else {
4242
+ done.push(
4243
+ step.set !== void 0 ? `set "${what}" \u2192 ${JSON.stringify(m?.value ?? step.set)}` : `${step.longPress ? "long-pressed" : "tapped"} "${what}"`
4244
+ );
4245
+ }
4246
+ }
4247
+ }
4248
+ if (!stepErrors.length && step.waitMs) {
4249
+ await delay4(step.waitMs);
4250
+ done.push(`waited ${step.waitMs}ms`);
4251
+ }
4252
+ if (!stepErrors.length && step.waitFor) {
4253
+ const r = await until((els) => els.some((e) => matches(e, step.waitFor)), budget);
4254
+ if (r.ok) done.push(`"${step.waitFor}" appeared (${r.ms}ms)`);
4255
+ else stepErrors.push(`"${step.waitFor}" never appeared within ${budget}ms`);
4256
+ }
4257
+ if (!stepErrors.length && step.waitGone) {
4258
+ const r = await until((els) => !els.some((e) => matches(e, step.waitGone)), budget);
4259
+ if (r.ok) done.push(`"${step.waitGone}" gone (${r.ms}ms)`);
4260
+ else stepErrors.push(`"${step.waitGone}" was still there after ${budget}ms`);
4261
+ }
4262
+ if (!stepErrors.length && step.expect) {
4263
+ const r = await until(
4264
+ (els) => els.some((e) => matches(e, step.expect)),
4265
+ step.timeoutMs ?? ASSERT_SETTLE_MS
4266
+ );
4267
+ if (r.ok) done.push(`"${step.expect}" is on screen`);
4268
+ else stepErrors.push(`expected "${step.expect}" on screen \u2014 not found`);
4269
+ }
4270
+ if (!stepErrors.length && step.expectGone) {
4271
+ const r = await until(
4272
+ (els) => !els.some((e) => matches(e, step.expectGone)),
4273
+ step.timeoutMs ?? ASSERT_SETTLE_MS
4274
+ );
4275
+ if (r.ok) done.push(`"${step.expectGone}" is gone`);
4276
+ else stepErrors.push(`expected "${step.expectGone}" to be gone \u2014 still on screen`);
4277
+ }
4278
+ } catch (e) {
4279
+ stepErrors.push(errMsg20(e));
4280
+ }
4281
+ if (stepErrors.length) {
4282
+ lines.push(`${label} \u2717 ${describeStep(step)} \u2014 ${stepErrors.join("; ")}`);
4283
+ failedAt = failedAt ?? i;
4284
+ if (!args.continueOnFailure) break;
4285
+ } else {
4286
+ okCount++;
4287
+ lines.push(`${label} \u2713 ${done.join(", ") || describeStep(step)}`);
4288
+ }
4289
+ }
4290
+ const elapsed = ((Date.now() - started) / 1e3).toFixed(1);
4291
+ 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)**`;
4292
+ const parts = [header, lines.join("\n")];
4293
+ if (include !== "none") {
4294
+ try {
4295
+ const els = await describe();
4296
+ const shown = els.filter((e) => e.interactive).slice(0, 30);
4297
+ parts.push(
4298
+ `**Screen now (${shown.length} interactive of ${els.length}):**
4299
+ ${shown.map(formatElement2).join("\n")}`
4300
+ );
4301
+ } catch (e) {
4302
+ parts.push(`**Screen now:** unavailable \u2014 ${cap2(errMsg20(e))}`);
4303
+ }
4304
+ }
4305
+ if (include === "all") {
4306
+ if (toolIds.has("network")) {
4307
+ const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
4308
+ const events = Array.isArray(payload) ? payload : payload?.events ?? [];
4309
+ const fresh = events.filter((e) => !beforeNetwork.has(e.id)).slice(-15);
4310
+ if (fresh.length) {
4311
+ parts.push(
4312
+ `**Network during this flow (${fresh.length}):**
4313
+ ` + fresh.map(
4314
+ (e) => `${e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705"} ${e.method ?? "GET"} ${e.path ?? e.url ?? "?"} \u2192 ${e.error ?? e.status ?? "pending"}`
4315
+ ).join("\n")
4316
+ );
4317
+ }
4318
+ }
4319
+ if (toolIds.has("console")) {
4320
+ const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
4321
+ const entries = payload?.entries ?? [];
4322
+ const fresh = entries.filter((e) => !beforeConsole.has(e.id) && (e.level === "error" || e.level === "warning")).slice(-10);
4323
+ if (fresh.length) {
4324
+ parts.push(
4325
+ `**Console during this flow (${fresh.length}):**
4326
+ ` + fresh.map((e) => `[${e.level === "error" ? "ERR" : "warn"}] ${cap2(firstLines(e.message ?? ""), 220)}`).join("\n")
4327
+ );
4328
+ }
4329
+ }
4330
+ }
4331
+ return text18(parts.join("\n\n"));
4332
+ } catch (e) {
4333
+ return fail20(errMsg20(e));
4334
+ }
4335
+ }
4336
+ );
4337
+ }
4338
+ function selector(needle) {
4339
+ return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
4340
+ }
4341
+ function describeStep(step) {
4342
+ if (step.tap) return `tap "${step.tap}"`;
4343
+ if (step.type !== void 0) return `type into "${step.into ?? "?"}"`;
4344
+ if (step.reload) return "reload";
4345
+ if (step.waitFor) return `waitFor "${step.waitFor}"`;
4346
+ if (step.waitGone) return `waitGone "${step.waitGone}"`;
4347
+ if (step.expect) return `expect "${step.expect}"`;
4348
+ return "step";
4349
+ }
4350
+ var cap2 = (s, n = 160) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
4351
+ var firstLines = (s, n = 3) => s.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, n).join(" \u23CE ");
4352
+
4353
+ // src/tools/packs/renders.ts
4354
+ import { z as z21 } from "zod";
4355
+ var UI_TOOL_ID2 = "highlight-updates";
4356
+ var text19 = (s) => ({ content: [{ type: "text", text: s }] });
4357
+ var fail21 = (message) => ({
4358
+ isError: true,
4359
+ content: [{ type: "text", text: message }]
4360
+ });
4361
+ var errMsg21 = (e) => e instanceof Error ? e.message : String(e);
4362
+ var delay5 = (ms) => new Promise((r) => setTimeout(r, ms));
4363
+ var DEFAULT_IDLE_MS = 3e3;
4364
+ var SETTLE_MS = 400;
4365
+ var DEFAULT_TOP = 12;
4366
+ var lastCapture = /* @__PURE__ */ new Map();
4367
+ var stepSchema2 = z21.object({
4368
+ tap: z21.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
4369
+ type: z21.string().optional().describe("Text to type (needs `into`)."),
4370
+ into: z21.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
4371
+ waitMs: z21.number().min(0).max(1e4).optional().describe("Pause after the action, in ms."),
4372
+ repeat: z21.number().min(1).max(20).optional().describe("Do this step N times (default 1). Useful for 'tap it five times'.")
4373
+ }).describe("One interaction to measure. Keep it to the thing you're asking about.");
4374
+ function selector2(needle) {
4375
+ return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
4376
+ }
4377
+ function dominant(causes) {
4378
+ if (!causes) return "unknown";
4379
+ let best = "unknown";
4380
+ let n = -1;
4381
+ for (const [k, v] of Object.entries(causes)) {
4382
+ if ((v ?? 0) > n) {
4383
+ n = v ?? 0;
4384
+ best = k;
4385
+ }
4386
+ }
4387
+ return best;
4388
+ }
4389
+ var CAUSE_HINT = {
4390
+ propsUnstable: "props changed by IDENTITY only (a fresh function/object from the parent) \u2014 fix upstream, not in the child",
4391
+ parent: "parent re-rendered and nothing of its own changed \u2014 a pure cascade",
4392
+ props: "props genuinely changed value",
4393
+ hooks: "its own state/hooks changed",
4394
+ mount: "first render"
4395
+ };
4396
+ function formatCapture(c, top) {
4397
+ const s = c.summary;
4398
+ const wasted = s.wastedRenders ?? 0;
4399
+ const pct = s.totalRenders > 0 ? Math.round(wasted / s.totalRenders * 100) : 0;
4400
+ const lines = [];
4401
+ const ms = s.totalRenderMs ?? 0;
4402
+ lines.push(
4403
+ `**${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}%)`
4404
+ );
4405
+ 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 ");
4406
+ if (causeLine) lines.push(`causes: ${causeLine}`);
4407
+ lines.push("");
4408
+ lines.push("| component | self ms | renders | cause | dragged along by | changed props |");
4409
+ lines.push("|---|---:|---:|---|---|---|");
4410
+ const rows = [...s.topComponents].sort((a, b) => (b.totalMs ?? 0) - (a.totalMs ?? 0) || b.renders - a.renders).slice(0, top);
4411
+ for (const r of rows) {
4412
+ lines.push(
4413
+ `| ${r.name} | ${(r.totalMs ?? 0).toFixed(1)} | ${r.renders} | ${dominant(r.causes)} | ${r.parentName ?? "\u2014"} | ${(r.changedProps ?? []).join(", ") || "\u2014"} |`
4414
+ );
4415
+ }
4416
+ if (s.topComponents.length > top) {
4417
+ lines.push(`_\u2026+${s.topComponents.length - top} more components_`);
4418
+ }
4419
+ const seen = new Set(rows.map((r) => dominant(r.causes)));
4420
+ const hints = [...seen].filter((k) => CAUSE_HINT[k]).map((k) => `\`${k}\` = ${CAUSE_HINT[k]}`);
4421
+ if (hints.length) {
4422
+ lines.push("");
4423
+ lines.push(hints.join(" \xB7 "));
4424
+ }
4425
+ lines.push("");
4426
+ lines.push(
4427
+ "`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."
4428
+ );
4429
+ return lines.join("\n");
4430
+ }
4431
+ function formatDelta(before, after) {
4432
+ const d = (a, b) => {
4433
+ const diff = b - a;
4434
+ return `${a} \u2192 ${b} (${diff > 0 ? "+" : ""}${diff})`;
4435
+ };
4436
+ const bs = before.summary;
4437
+ const as_ = after.summary;
4438
+ const lines = [`**vs "${before.label}"**`];
4439
+ lines.push(`- total renders: ${d(bs.totalRenders, as_.totalRenders)}`);
4440
+ lines.push(`- wasted: ${d(bs.wastedRenders ?? 0, as_.wastedRenders ?? 0)}`);
4441
+ const beforeByName = new Map(bs.topComponents.map((r) => [r.name, r.renders]));
4442
+ const moved = [];
4443
+ for (const r of as_.topComponents.slice(0, 20)) {
4444
+ const was = beforeByName.get(r.name) ?? 0;
4445
+ if (was !== r.renders) moved.push(`${r.name} ${d(was, r.renders)}`);
4446
+ beforeByName.delete(r.name);
4447
+ }
4448
+ for (const [name, was] of beforeByName) {
4449
+ if (was > 0) moved.push(`${name} ${d(was, 0)}`);
4450
+ }
4451
+ if (moved.length) lines.push(`- per component: ${moved.slice(0, 10).join(" \xB7 ")}`);
4452
+ const fewer = as_.totalRenders < bs.totalRenders;
4453
+ const more = as_.totalRenders > bs.totalRenders;
4454
+ lines.push(
4455
+ 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."
4456
+ );
4457
+ return lines.join("\n");
4458
+ }
4459
+ function registerRenderTools(server, sync) {
4460
+ server.registerTool(
4461
+ "measure_renders",
4462
+ {
4463
+ 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.",
4464
+ inputSchema: {
4465
+ steps: z21.array(stepSchema2).max(20).optional().describe(
4466
+ "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."
4467
+ ),
4468
+ idleMs: z21.number().min(500).max(2e4).optional().describe(`Idle sample length when no steps are given. Default ${DEFAULT_IDLE_MS}.`),
4469
+ label: z21.string().optional().describe('Name this measurement, e.g. "before fix". Shown in the next comparison.'),
4470
+ compareToPrevious: z21.boolean().optional().describe("Diff against the previous measure_renders on this device. Use after an edit to prove the fix."),
4471
+ top: z21.number().min(1).max(40).optional().describe(`Components to list. Default ${DEFAULT_TOP}.`),
4472
+ deviceId: z21.string().optional().describe("Target device id (auto-selected if one is connected).")
4473
+ }
4474
+ },
4475
+ async (args) => {
4476
+ try {
4477
+ const target = sync.requireProDevice(args.deviceId);
4478
+ const device = sync.connectedDevices().find((d) => d.deviceId === target);
4479
+ const uiTool = (device?.tools ?? []).find((t) => t.toolId === UI_TOOL_ID2);
4480
+ if (!uiTool) {
4481
+ return fail21(
4482
+ "This app doesn't expose the highlight-updates tool, so renders can't be measured. Install @buoy-gg/highlight-updates."
4483
+ );
4484
+ }
4485
+ if (!(uiTool.actions ?? []).includes("beginMeasurement")) {
4486
+ return fail21(
4487
+ "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."
4488
+ );
4489
+ }
4490
+ const began = await sync.callAction(UI_TOOL_ID2, "beginMeasurement", {}, target);
4491
+ if (began && began.ok === false) {
4492
+ return fail21(`Can't measure renders: ${began.reason ?? "capture unavailable"}`);
4493
+ }
4494
+ await delay5(SETTLE_MS);
4495
+ const startedAt = Date.now();
4496
+ const notes = [];
4497
+ if (args.steps?.length) {
4498
+ for (const step of args.steps) {
4499
+ const times = step.repeat ?? 1;
4500
+ for (let i = 0; i < times; i++) {
4501
+ if (step.type !== void 0) {
4502
+ const sel = step.into ?? step.tap;
4503
+ if (!sel) {
4504
+ notes.push("a `type` step needs `into`");
4505
+ continue;
4506
+ }
4507
+ const res = await sync.callAction(
4508
+ UI_TOOL_ID2,
4509
+ "tapElement",
4510
+ { ...selector2(sel), text: step.type },
4511
+ target
4512
+ );
4513
+ if (!res?.tapped) notes.push(res?.reason ?? `could not type into "${sel}"`);
4514
+ else if (res.matched?.via && res.matched.via !== "onChangeText") {
4515
+ notes.push(`"${sel}" is not a text input \u2014 it ran ${res.matched.via}, nothing was typed`);
4516
+ }
4517
+ } else if (step.tap) {
4518
+ const res = await sync.callAction(
4519
+ UI_TOOL_ID2,
4520
+ "tapElement",
4521
+ selector2(step.tap),
4522
+ target
4523
+ );
4524
+ if (!res?.tapped) notes.push(res?.reason ?? `no element matched "${step.tap}"`);
4525
+ }
4526
+ if (step.waitMs) await delay5(step.waitMs);
4527
+ }
4528
+ }
4529
+ await delay5(SETTLE_MS);
4530
+ } else {
4531
+ await delay5(args.idleMs ?? DEFAULT_IDLE_MS);
4532
+ }
4533
+ const windowMs = Date.now() - startedAt;
4534
+ const ended = await sync.callAction(UI_TOOL_ID2, "endMeasurement", {}, target);
4535
+ const summary = ended?.summary;
4536
+ if (!summary) {
4537
+ return fail21(
4538
+ "The capture window closed without returning anything. If the app reloaded mid-measurement, try again."
4539
+ );
4540
+ }
4541
+ const capture = {
4542
+ label: args.label ?? (args.steps?.length ? "interaction" : "idle"),
4543
+ windowMs,
4544
+ summary
4545
+ };
4546
+ const prev = lastCapture.get(target);
4547
+ lastCapture.set(target, capture);
4548
+ 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`;
4549
+ const parts = [`**measure_renders \u2014 ${what}**`, "", formatCapture(capture, args.top ?? DEFAULT_TOP)];
4550
+ if (args.compareToPrevious) {
4551
+ parts.push("", prev ? formatDelta(prev, capture) : "_No previous measurement on this device to compare against._");
4552
+ }
4553
+ if (notes.length) parts.push("", `\u26A0\uFE0F ${notes.join("; ")}`);
4554
+ if (summary.totalRenders === 0) {
4555
+ parts.push(
4556
+ "",
4557
+ "_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._"
4558
+ );
4559
+ } else if (!args.compareToPrevious) {
4560
+ parts.push("", "_Fixed something? Call this again with the same steps and `compareToPrevious:true` to prove it landed._");
4561
+ }
4562
+ return text19(parts.join("\n"));
4563
+ } catch (e) {
4564
+ return fail21(errMsg21(e));
4565
+ }
4566
+ }
4567
+ );
4568
+ }
4569
+
4570
+ // src/verify/editWatcher.ts
4571
+ import fs3 from "fs";
4572
+ import path3 from "path";
4573
+ var WATCHED_EXT = /* @__PURE__ */ new Set([
4574
+ ".ts",
4575
+ ".tsx",
4576
+ ".js",
4577
+ ".jsx",
4578
+ ".mjs",
4579
+ ".cjs",
4580
+ ".json"
4581
+ ]);
4582
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
4583
+ "node_modules",
4584
+ ".git",
4585
+ ".expo",
4586
+ "ios",
4587
+ "android",
4588
+ "dist",
4589
+ "lib",
4590
+ "build",
4591
+ ".next",
4592
+ "coverage",
4593
+ "__snapshots__"
4594
+ ]);
4595
+ var DEBOUNCE_MS = 400;
4596
+ function resolveMode(raw) {
4597
+ const v = (raw ?? "").trim().toLowerCase();
4598
+ if (v === "always" || v === "never" || v === "auto") return v;
4599
+ return "auto";
4600
+ }
4601
+ var EditWatcher = class {
4602
+ constructor() {
4603
+ this.watcher = null;
4604
+ this.changed = /* @__PURE__ */ new Set();
4605
+ this.lastEditAt = 0;
4606
+ this.nudgedForCurrentBatch = false;
4607
+ this.mode = "auto";
4608
+ this.started = false;
4609
+ }
4610
+ start(rootDir, mode) {
4611
+ this.mode = mode;
4612
+ if (mode === "never" || this.started) return;
4613
+ this.started = true;
4614
+ try {
4615
+ this.watcher = fs3.watch(
4616
+ rootDir,
4617
+ { recursive: true },
4618
+ (_event, filename) => {
4619
+ if (!filename) return;
4620
+ const rel2 = String(filename);
4621
+ if (rel2.split(path3.sep).some((seg) => IGNORED_DIRS.has(seg))) return;
4622
+ if (!WATCHED_EXT.has(path3.extname(rel2))) return;
4623
+ this.noteEdit(rel2);
4624
+ }
4625
+ );
4626
+ this.watcher.on("error", () => this.stop());
4627
+ } catch {
4628
+ this.stop();
4629
+ }
4630
+ }
4631
+ /** Record an edit. Public so the nudge policy can be tested without fs.watch. */
4632
+ noteEdit(rel2) {
4633
+ const now = Date.now();
4634
+ if (now - this.lastEditAt > DEBOUNCE_MS) this.nudgedForCurrentBatch = false;
4635
+ this.lastEditAt = now;
4636
+ if (this.changed.size < 50) this.changed.add(rel2);
4637
+ }
4638
+ stop() {
4639
+ try {
4640
+ this.watcher?.close();
4641
+ } catch {
4642
+ }
4643
+ this.watcher = null;
4644
+ }
4645
+ /** Something on the device was actually checked — the edits are accounted for. */
4646
+ markVerified() {
4647
+ this.changed.clear();
4648
+ this.nudgedForCurrentBatch = false;
4649
+ }
4650
+ hasUnverifiedEdits() {
4651
+ return this.changed.size > 0;
4652
+ }
4653
+ /**
4654
+ * The line to append to a tool result, or null to stay quiet.
4655
+ *
4656
+ * `auto` says it once per burst of edits, so it lands when the agent is
4657
+ * mid-loop and then gets out of the way. `always` repeats while anything is
4658
+ * unverified, for people who want the belt and braces.
4659
+ */
4660
+ nudge() {
4661
+ if (this.mode === "never" || !this.hasUnverifiedEdits()) return null;
4662
+ if (this.mode === "auto") {
4663
+ if (this.nudgedForCurrentBatch) return null;
4664
+ this.nudgedForCurrentBatch = true;
4665
+ }
4666
+ const files = [...this.changed];
4667
+ const shown = files.slice(0, 3).join(", ");
4668
+ const more = files.length > 3 ? ` +${files.length - 3} more` : "";
4669
+ return `
4670
+
4671
+ ---
4672
+ \u26A0\uFE0F **${files.length} source file${files.length === 1 ? "" : "s"} changed since anything was last checked on the device** (${shown}${more}).
4673
+ 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.`;
4674
+ }
4675
+ };
4676
+ var editWatcher = new EditWatcher();
4677
+
3542
4678
  // src/server.ts
3543
4679
  var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
3544
4680
 
3545
4681
  Buoy tools live inside the app and sync to a local broker; this server connects to that
3546
- broker as a dashboard client. Start by calling \`list_devices\` to see which device(s) are
3547
- connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose.
4682
+ broker as a dashboard client.
4683
+
4684
+ DEBUGGING A RUNNING APP? Call \`get_triage\` FIRST \u2014 one call returns the connected device,
4685
+ recent console errors (including [FATAL] crashes), recent network requests with statuses,
4686
+ persisted storage keys, and what's on screen. It replaces the usual five-call opening
4687
+ sequence and tells you which tool to drill into. Read runtime facts before hypothesizing
4688
+ from source: the app's actual state beats a guess about what the code should do.
4689
+
4690
+ Routing after triage \u2014 match the SYMPTOM to the tool:
4691
+ - Value wrong/stale/NaN, or broken only after an upgrade \u2192 \`get_storage includeValues:true\`
4692
+ (a bad persisted value is a top root cause, and it survives reinstalls of the JS).
4693
+ - Failed request, wrong data from the API, auth/session bug \u2192 \`get_network_requests
4694
+ includeBodies:true\` and read the response BODY.
4695
+ - Crash or redbox \u2192 \`get_console\` (fatals are captured, and still readable after the app
4696
+ dies), then \`get_storage\` for the state that triggered it.
4697
+ - Data on screen doesn't match the API \u2192 \`get_react_query\` / \`get_zustand_state\` /
4698
+ \`get_redux_state\` / \`get_jotai_state\` for the cache or store between them.
4699
+
4700
+ AFTER YOU EDIT, PROVE IT on the device \u2014 the most common way a fix fails is being incomplete,
4701
+ not being wrong. Reload (\`run_flow\` step \`{reload:true}\`, or \`reload_app\`) and re-drive EVERY
4702
+ screen the symptom could appear on, asserting the corrected value: a price shown in three places
4703
+ has to be right in all three, and a migration only counts if it survives a restart. A fix you
4704
+ haven't watched work is a guess.
4705
+
4706
+ Use \`list_devices\` when you need the full tool/action inventory or several devices are
4707
+ connected (\`get_triage\` already names the active one).
3548
4708
 
3549
4709
  To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
3550
4710
  react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
@@ -3563,15 +4723,28 @@ Need to SEE a component (verify a UI change, check styling/layout)? Use \`screen
3563
4723
  a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
3564
4724
  iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
3565
4725
 
3566
- Need to DRIVE the UI (tap a button, follow a flow)? Use \`describe_screen\` to list the on-screen
3567
- elements (accessibility-style tree with tap points + testIDs, no screenshot), then \`tap_element\`
3568
- ({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
3569
- it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
4726
+ Need to DRIVE the UI? Reach for \`run_flow\` FIRST: it runs a whole scripted sequence
4727
+ (tap / type / set / reload / waitFor / expect) in ONE call and reports the per-step result, the
4728
+ final screen, and the network + console output the flow produced. Reproducing a bug and verifying
4729
+ a fix are one call each instead of a dozen alternating reads and taps, and a failed step reports
4730
+ the screen at the point it broke. Drop to \`describe_screen\` (list on-screen elements with testIDs
4731
+ and tap points) + \`tap_element\` only for one-off pokes or when you don't yet know what's there.
4732
+
4733
+ Screen slow / laggy / janky / stutters, typing lags, or "the whole page re-renders when I tap
4734
+ one thing"? Start with \`measure_renders\` \u2014 one call, no test route needed. Pass \`steps\` for the
4735
+ interaction you're actually asking about (a tap, some typing); an idle sample says nothing about
4736
+ what a tap costs. It returns per-component render counts, WHY each rendered (parent cascade / own
4737
+ state / changed props), which parent dragged it along, and \`wasted\` \u2014 the renders that produced
4738
+ nothing new. After you edit, call it again with the same steps and \`compareToPrevious:true\`:
4739
+ re-render fixes routinely move the cost somewhere else instead of removing it, and only a second
4740
+ measurement catches that.
3570
4741
 
3571
4742
  JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
3572
4743
  \`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
3573
4744
  freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
3574
4745
  Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
4746
+ This is about TASKS on the thread \u2014 for React components re-rendering too often, it's
4747
+ \`measure_renders\`, not this.
3575
4748
 
3576
4749
  Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
3577
4750
  expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
@@ -3607,11 +4780,46 @@ If no device is connected, open the app with Buoy devtools running and try again
3607
4780
  The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
3608
4781
  require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
3609
4782
  required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
4783
+ var VERIFYING_TOOLS = /* @__PURE__ */ new Set([
4784
+ "measure_renders",
4785
+ "run_flow",
4786
+ "run_benchmark_batch",
4787
+ "get_triage",
4788
+ "tap_element",
4789
+ "describe_screen",
4790
+ "get_console",
4791
+ "get_network_requests",
4792
+ "get_events",
4793
+ "get_snapshot"
4794
+ ]);
3610
4795
  function createMcpServer(sync) {
3611
4796
  const server = new McpServer(
3612
4797
  { name: "buoy", version: VERSION },
3613
4798
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
3614
4799
  );
4800
+ editWatcher.start(process.cwd(), resolveMode(process.env.BUOY_VERIFY));
4801
+ const originalRegister = server.registerTool.bind(server);
4802
+ server.registerTool = ((name, config, handler) => originalRegister(name, config, (async (...args) => {
4803
+ const result = await handler(...args);
4804
+ const errored = result?.isError === true;
4805
+ if (VERIFYING_TOOLS.has(name) && !errored) {
4806
+ editWatcher.markVerified();
4807
+ return result;
4808
+ }
4809
+ const note = editWatcher.nudge();
4810
+ if (!note) return result;
4811
+ const r = result;
4812
+ if (Array.isArray(r?.content)) {
4813
+ return {
4814
+ ...r,
4815
+ content: [...r.content, { type: "text", text: note }]
4816
+ };
4817
+ }
4818
+ return result;
4819
+ })));
4820
+ registerTriageTools(server, sync);
4821
+ registerFlowTools(server, sync);
4822
+ registerRenderTools(server, sync);
3615
4823
  registerGenericTools(server, sync);
3616
4824
  registerPerfMonitorTools(server, sync);
3617
4825
  registerEventsTools(server, sync);
@@ -3633,32 +4841,37 @@ function createMcpServer(sync) {
3633
4841
  return server;
3634
4842
  }
3635
4843
  var deviceIdField = {
3636
- deviceId: z19.string().optional().describe(
4844
+ deviceId: z22.string().optional().describe(
3637
4845
  'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
3638
4846
  )
3639
4847
  };
3640
4848
 
3641
4849
  // src/init.ts
3642
4850
  import { spawnSync } from "child_process";
3643
- import fs3 from "fs";
4851
+ import fs4 from "fs";
3644
4852
  import os3 from "os";
3645
- import path3 from "path";
4853
+ import path4 from "path";
4854
+ function defaultEnv() {
4855
+ return { BUOY_VERIFY: "auto" };
4856
+ }
3646
4857
  var PUBLIC_REGISTRY = "https://registry.npmjs.org";
3647
- var PROBE_TIMEOUT_MS2 = 3e3;
4858
+ var PROBE_TIMEOUT_MS3 = 3e3;
3648
4859
  function runInit(opts = {}) {
3649
4860
  const cwd = opts.cwd ?? process.cwd();
3650
- const skillSrcDir = opts.skillSrcDir ?? path3.resolve(__dirname, "../skill");
4861
+ const skillSrcDir = opts.skillSrcDir ?? path4.resolve(__dirname, "../skill");
3651
4862
  const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
3652
4863
  const entry = resolveEntry(opts, log);
3653
4864
  const done = [];
3654
- upsertMcpConfig(path3.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
3655
- if (fs3.existsSync(path3.join(cwd, ".cursor"))) {
3656
- upsertMcpConfig(path3.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
4865
+ upsertMcpConfig(path4.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
4866
+ if (fs4.existsSync(path4.join(cwd, ".cursor"))) {
4867
+ upsertMcpConfig(path4.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
3657
4868
  }
3658
- if (fs3.existsSync(path3.join(cwd, ".vscode"))) {
3659
- upsertMcpConfig(path3.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
4869
+ if (fs4.existsSync(path4.join(cwd, ".vscode"))) {
4870
+ upsertMcpConfig(path4.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
3660
4871
  }
3661
4872
  installSkills(skillSrcDir, cwd, done);
4873
+ appendDebuggingGuide(path4.join(cwd, "CLAUDE.md"), cwd, done);
4874
+ appendDebuggingGuide(path4.join(cwd, "AGENTS.md"), cwd, done);
3662
4875
  log("Buoy MCP installed:");
3663
4876
  for (const line of done) log(" \u2022 " + line);
3664
4877
  log("");
@@ -3678,7 +4891,7 @@ function runInit(opts = {}) {
3678
4891
  }
3679
4892
  function resolveEntry(opts, log) {
3680
4893
  if (opts.cliPath) {
3681
- return { type: "stdio", command: "node", args: [opts.cliPath] };
4894
+ return { type: "stdio", command: "node", args: [opts.cliPath], env: defaultEnv() };
3682
4895
  }
3683
4896
  const mode = opts.mode ?? "auto";
3684
4897
  if (mode === "npx") {
@@ -3697,21 +4910,21 @@ function resolveEntry(opts, log) {
3697
4910
  return localEntry(opts, log);
3698
4911
  }
3699
4912
  function npxEntry() {
3700
- return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
4913
+ return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`], env: defaultEnv() };
3701
4914
  }
3702
4915
  function localEntry(opts, log) {
3703
- const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? path3.join(os3.homedir(), ".buoy-mcp");
4916
+ const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? path4.join(os3.homedir(), ".buoy-mcp");
3704
4917
  const registry = opts.registry ?? PUBLIC_REGISTRY;
3705
4918
  const cliPath = installLocalCopy(installDir, registry, log);
3706
4919
  if (cliPath) {
3707
- return { type: "stdio", command: "node", args: [cliPath] };
4920
+ return { type: "stdio", command: "node", args: [cliPath], env: defaultEnv() };
3708
4921
  }
3709
4922
  log("\u26A0 local install failed \u2014 writing the npx entry instead (see the error above).");
3710
4923
  return npxEntry();
3711
4924
  }
3712
4925
  function installLocalCopy(installDir, registry, log) {
3713
4926
  const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
3714
- fs3.mkdirSync(installDir, { recursive: true });
4927
+ fs4.mkdirSync(installDir, { recursive: true });
3715
4928
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
3716
4929
  log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
3717
4930
  const res = spawnSync(
@@ -3734,8 +4947,8 @@ function installLocalCopy(installDir, registry, log) {
3734
4947
  log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
3735
4948
  return null;
3736
4949
  }
3737
- const cliPath = path3.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
3738
- if (!fs3.existsSync(cliPath)) {
4950
+ const cliPath = path4.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
4951
+ if (!fs4.existsSync(cliPath)) {
3739
4952
  log(`\u26A0 install finished but ${cliPath} is missing.`);
3740
4953
  return null;
3741
4954
  }
@@ -3757,7 +4970,7 @@ function effectiveRegistry() {
3757
4970
  }
3758
4971
  function npmConfigGet(key) {
3759
4972
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
3760
- const res = spawnSync(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS2 });
4973
+ const res = spawnSync(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 });
3761
4974
  if (res.status !== 0 || typeof res.stdout !== "string") return null;
3762
4975
  const val = res.stdout.trim();
3763
4976
  if (!val || val === "undefined" || val === "null") return null;
@@ -3766,8 +4979,8 @@ function npmConfigGet(key) {
3766
4979
  function hostReachable(registry) {
3767
4980
  const done = spawnSync(
3768
4981
  process.execPath,
3769
- ["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS2)],
3770
- { encoding: "utf8", timeout: PROBE_TIMEOUT_MS2 + 2e3 }
4982
+ ["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS3)],
4983
+ { encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 + 2e3 }
3771
4984
  );
3772
4985
  return done.status === 0;
3773
4986
  }
@@ -3787,52 +5000,129 @@ fetch(url, { method: 'HEAD', signal: c.signal })
3787
5000
  `;
3788
5001
  function upsertMcpConfig(file, key, entry, cwd, done) {
3789
5002
  let json3 = {};
3790
- if (fs3.existsSync(file)) {
5003
+ if (fs4.existsSync(file)) {
3791
5004
  try {
3792
- json3 = JSON.parse(fs3.readFileSync(file, "utf8"));
5005
+ json3 = JSON.parse(fs4.readFileSync(file, "utf8"));
3793
5006
  } catch {
3794
- fs3.copyFileSync(file, file + ".bak");
5007
+ fs4.copyFileSync(file, file + ".bak");
3795
5008
  done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
3796
5009
  json3 = {};
3797
5010
  }
3798
5011
  } else {
3799
- fs3.mkdirSync(path3.dirname(file), { recursive: true });
5012
+ fs4.mkdirSync(path4.dirname(file), { recursive: true });
3800
5013
  }
3801
5014
  const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
3802
5015
  const existed = "buoy" in servers;
3803
5016
  servers.buoy = entry;
3804
5017
  json3[key] = servers;
3805
- fs3.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
5018
+ fs4.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
3806
5019
  done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
3807
5020
  }
5021
+ var GUIDE_HEADING = "# Debugging with Buoy";
5022
+ var DEBUGGING_GUIDE = `${GUIDE_HEADING}
5023
+
5024
+ This app's runtime is inspectable while it runs: the Buoy MCP tools read live
5025
+ state straight from the device. Route by symptom before reading source:
5026
+
5027
+ - \`get_triage\` \u2014 START HERE. One call: device, recent console errors and
5028
+ crashes, recent network requests, storage keys, and what's on screen. It
5029
+ usually names the problem, and always names the tool to drill into next.
5030
+ - \`run_flow\` \u2014 reproduce a bug or verify a fix in ONE call: a scripted
5031
+ tap/type/wait/assert sequence that returns the final screen plus the network
5032
+ and console output it produced. Use it instead of alternating
5033
+ \`describe_screen\` and \`tap_element\` a dozen times.
5034
+ - \`get_events\` \u2014 the universal activity timeline (network, state changes,
5035
+ storage writes, routes, renders). Check it BEFORE driving the UI to
5036
+ reproduce a bug \u2014 the failure is usually already recorded.
5037
+ - \`get_network_requests\` \u2014 API data wrong or calls failing \u2192 read the actual
5038
+ request + response bodies instead of guessing from code. Boot-time requests
5039
+ are captured too.
5040
+ - \`get_console\` \u2014 crashes and errors, including FATAL uncaught JS errors
5041
+ (tagged \`[FATAL]\`, with the component that crashed). Readable even after the
5042
+ app dies, so a redboxed app still tells you why.
5043
+ - \`get_storage\` \u2014 data wrong or missing after an app restart \u2192 read the
5044
+ persisted values FIRST.
5045
+ - \`reload_app\` \u2014 recover a crashed app (the other tools stop answering until
5046
+ you do) or pick up a change Fast Refresh missed.
5047
+ - \`get_snapshot\` \u2014 full current state of any one tool when the compact views
5048
+ aren't enough.
5049
+
5050
+ Runtime facts beat guesses \u2014 and after you edit, prove the fix on the device:
5051
+ reload, then re-drive EVERY screen the symptom could show up on and assert the
5052
+ corrected value. Fixes usually fail by being incomplete, not by being wrong.
5053
+
5054
+ ## Don't report a fix you haven't watched work
5055
+
5056
+ Saying "fixed" is a claim about how the app behaves, so check it the same way
5057
+ you'd check anything else about the app \u2014 by looking at it. Re-run the exact
5058
+ interaction that was broken and report what the numbers did:
5059
+
5060
+ - \`measure_renders\` with the same \`steps\` and \`compareToPrevious: true\` for a
5061
+ performance change \u2014 it prints the before/after delta and flags a change that
5062
+ made things worse.
5063
+ - \`run_flow\` re-driving the broken screen for a behaviour change.
5064
+
5065
+ Two things this catches that reading cannot: a fix that is right but incomplete
5066
+ (the value was wrong in three places and you corrected one), and a fix that
5067
+ moved the cost instead of removing it \u2014 the usual failure mode for re-render
5068
+ work, where a page gets faster to type in and slower to tap.
5069
+
5070
+ If the app changed since anything was last checked, Buoy will say so in a tool
5071
+ result. Whether that's worth acting on is your call \u2014 a doc edit isn't worth a
5072
+ device check, a state-management change is. Set \`BUOY_VERIFY\` in the MCP config
5073
+ to \`always\` (repeat until checked) or \`never\` (silent) to change how often it
5074
+ mentions it.
5075
+ `;
5076
+ function appendDebuggingGuide(file, cwd, done) {
5077
+ let existing = "";
5078
+ if (fs4.existsSync(file)) {
5079
+ try {
5080
+ existing = fs4.readFileSync(file, "utf8");
5081
+ } catch {
5082
+ done.push(`\u26A0 could not read ${rel(file, cwd)} \u2014 skipped the debugging guide`);
5083
+ return;
5084
+ }
5085
+ if (existing.includes(GUIDE_HEADING)) {
5086
+ done.push(`${rel(file, cwd)} already has the "${GUIDE_HEADING}" block`);
5087
+ return;
5088
+ }
5089
+ }
5090
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
5091
+ try {
5092
+ fs4.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
5093
+ done.push(`${existing ? "appended" : "created"} "${GUIDE_HEADING}" block in ${rel(file, cwd)}`);
5094
+ } catch {
5095
+ done.push(`\u26A0 could not write ${rel(file, cwd)} \u2014 skipped the debugging guide`);
5096
+ }
5097
+ }
3808
5098
  function installSkills(skillSrcDir, cwd, done) {
3809
- if (!fs3.existsSync(skillSrcDir)) {
5099
+ if (!fs4.existsSync(skillSrcDir)) {
3810
5100
  done.push("\u26A0 no bundled skills found \u2014 skipped");
3811
5101
  return;
3812
5102
  }
3813
- const skills = fs3.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && fs3.existsSync(path3.join(skillSrcDir, e.name, "SKILL.md")));
5103
+ const skills = fs4.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && fs4.existsSync(path4.join(skillSrcDir, e.name, "SKILL.md")));
3814
5104
  if (skills.length === 0) {
3815
5105
  done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
3816
5106
  return;
3817
5107
  }
3818
5108
  for (const skill of skills) {
3819
- const from = path3.join(skillSrcDir, skill.name);
3820
- const to = path3.join(cwd, ".claude", "skills", skill.name);
5109
+ const from = path4.join(skillSrcDir, skill.name);
5110
+ const to = path4.join(cwd, ".claude", "skills", skill.name);
3821
5111
  copyDir(from, to);
3822
- done.push(`installed the ${skill.name} skill \u2192 ${rel(path3.join(to, "SKILL.md"), cwd)}`);
5112
+ done.push(`installed the ${skill.name} skill \u2192 ${rel(path4.join(to, "SKILL.md"), cwd)}`);
3823
5113
  }
3824
5114
  }
3825
5115
  function copyDir(from, to) {
3826
- fs3.mkdirSync(to, { recursive: true });
3827
- for (const entry of fs3.readdirSync(from, { withFileTypes: true })) {
3828
- const s = path3.join(from, entry.name);
3829
- const d = path3.join(to, entry.name);
5116
+ fs4.mkdirSync(to, { recursive: true });
5117
+ for (const entry of fs4.readdirSync(from, { withFileTypes: true })) {
5118
+ const s = path4.join(from, entry.name);
5119
+ const d = path4.join(to, entry.name);
3830
5120
  if (entry.isDirectory()) copyDir(s, d);
3831
- else fs3.copyFileSync(s, d);
5121
+ else fs4.copyFileSync(s, d);
3832
5122
  }
3833
5123
  }
3834
5124
  function rel(file, cwd) {
3835
- const r = path3.relative(cwd, file);
5125
+ const r = path4.relative(cwd, file);
3836
5126
  return r && !r.startsWith("..") ? r : file;
3837
5127
  }
3838
5128