@buoy-gg/mcp 7.0.36 → 7.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -26,9 +26,177 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  // src/cli.ts
27
27
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
28
28
 
29
+ // src/sync/NodeAccount.ts
30
+ var import_node_fs = require("fs");
31
+ var import_node_path = require("path");
32
+ var import_node_os = require("os");
33
+ var import_node_crypto = require("crypto");
34
+ var import_headless = require("@buoy-gg/license/headless");
35
+ var KEY_NAMES = ["BUOY_LICENSE_KEY", "BUOY_KEY", "EXPO_PUBLIC_BUOY_KEY", "VITE_BUOY_KEY", "REACT_APP_BUOY_KEY"];
36
+ function readProjectKey(projectDir, env = process.env) {
37
+ for (const name of KEY_NAMES) if (env[name] !== void 0) return env[name].trim().toUpperCase() || null;
38
+ for (const file of [".env.development.local", ".env.local", ".env"]) {
39
+ let contents;
40
+ try {
41
+ contents = (0, import_node_fs.readFileSync)((0, import_node_path.join)(projectDir, file), "utf8");
42
+ } catch {
43
+ continue;
44
+ }
45
+ const values = /* @__PURE__ */ new Map();
46
+ for (const line of contents.split(/\r?\n/)) {
47
+ const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);
48
+ if (!match) continue;
49
+ const raw = match[2].trim();
50
+ const quoted = raw.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);
51
+ values.set(match[1], quoted ? quoted[2] : raw.split("#")[0].trim());
52
+ }
53
+ for (const name of KEY_NAMES) if (values.has(name)) return values.get(name).trim().toUpperCase() || null;
54
+ }
55
+ return null;
56
+ }
57
+ var NodeAccount = class {
58
+ constructor(changed, opts = {}) {
59
+ this.changed = changed;
60
+ this.key = null;
61
+ this.cache = null;
62
+ this.timer = null;
63
+ this.generation = 0;
64
+ this.busy = false;
65
+ this.nextCheck = 0;
66
+ this.retry = 0;
67
+ this.lastAdmission = false;
68
+ this.hasAccess = () => (0, import_headless.resolveAccountAccess)({
69
+ initialized: true,
70
+ validating: false,
71
+ key: this.key,
72
+ tierSource: this.cache?.tier ? "key" : "none",
73
+ verifiedAt: this.cache?.cachedAt ?? null
74
+ }).status === "admitted";
75
+ this.projectDir = (0, import_node_path.resolve)(opts.projectDir ?? process.env.BUOY_PROJECT_DIR ?? process.cwd());
76
+ const project = (0, import_node_crypto.createHash)("sha256").update(this.projectDir).digest("hex").slice(0, 20);
77
+ this.cacheFile = opts.cacheFile ?? (0, import_node_path.join)((0, import_node_os.homedir)(), ".buoy", `account-${project}.json`);
78
+ }
79
+ start() {
80
+ if (this.timer) return;
81
+ try {
82
+ const cached = JSON.parse((0, import_node_fs.readFileSync)(this.cacheFile, "utf8"));
83
+ if (cached.valid && (0, import_headless.verifyCacheSignature)(cached)) this.cache = cached;
84
+ } catch {
85
+ }
86
+ void this.tick();
87
+ this.timer = setInterval(() => {
88
+ void this.tick();
89
+ }, 1e3);
90
+ this.timer.unref();
91
+ }
92
+ stop() {
93
+ ++this.generation;
94
+ if (this.timer) clearInterval(this.timer);
95
+ this.timer = null;
96
+ this.key = null;
97
+ this.cache = null;
98
+ this.notify();
99
+ }
100
+ notify() {
101
+ const admitted = this.hasAccess();
102
+ if (admitted === this.lastAdmission) return;
103
+ this.lastAdmission = admitted;
104
+ this.changed();
105
+ }
106
+ async tick() {
107
+ const selected = readProjectKey(this.projectDir);
108
+ if (selected !== this.key) {
109
+ ++this.generation;
110
+ this.key = selected;
111
+ if (this.cache?.licenseKey !== selected) this.cache = null;
112
+ this.nextCheck = 0;
113
+ this.retry = 0;
114
+ if (!selected) this.removeCache();
115
+ }
116
+ this.notify();
117
+ if (!selected || this.busy || Date.now() < this.nextCheck) return;
118
+ if (this.cache && this.hasAccess() && Date.now() - this.cache.lastValidatedAt < import_headless.LICENSE_LAUNCH_VALIDATION_TTL && this.nextCheck === 0) {
119
+ this.nextCheck = this.cache.lastValidatedAt + import_headless.LICENSE_LAUNCH_VALIDATION_TTL;
120
+ return;
121
+ }
122
+ this.busy = true;
123
+ const startedAt = this.generation;
124
+ try {
125
+ if ((0, import_headless.isBuoyToken)(selected)) {
126
+ const claims = (0, import_headless.verifyFreeToken)(selected);
127
+ if (!claims) {
128
+ this.cache = null;
129
+ this.removeCache();
130
+ } else {
131
+ const now2 = Date.now();
132
+ this.cache = (0, import_headless.signCache)({
133
+ licenseKey: selected,
134
+ licenseId: `token:${claims.accountHash}`,
135
+ valid: true,
136
+ tier: "free",
137
+ isPro: false,
138
+ expiresAt: null,
139
+ cachedAt: now2,
140
+ lastValidatedAt: now2
141
+ });
142
+ this.persistCache();
143
+ }
144
+ this.nextCheck = Date.now() + import_headless.LICENSE_REVALIDATION_INTERVAL;
145
+ return;
146
+ }
147
+ const response = await (0, import_headless.validateLicense)(selected);
148
+ if (startedAt !== this.generation || readProjectKey(this.projectDir) !== selected) return;
149
+ const product = response.data?.relationships?.product?.data?.id;
150
+ const expired = response.meta.code === "EXPIRED" && ((0, import_headless.isRecognizedAccount)(response.data) || this.cache?.licenseKey === selected);
151
+ if (product && product !== import_headless.KEYGEN_CONFIG.productId || !expired && !(0, import_headless.isAcceptedValidation)(response)) {
152
+ this.cache = null;
153
+ this.removeCache();
154
+ this.nextCheck = Date.now() + import_headless.LICENSE_REVALIDATION_INTERVAL;
155
+ return;
156
+ }
157
+ const tier = expired ? "free" : (0, import_headless.resolveTier)(response.data);
158
+ const now = Date.now();
159
+ this.cache = (0, import_headless.signCache)({
160
+ licenseKey: selected,
161
+ licenseId: response.data?.id ?? "",
162
+ valid: true,
163
+ tier,
164
+ isPro: tier === "pro",
165
+ expiresAt: expired ? null : response.data.attributes.expiry,
166
+ cachedAt: now,
167
+ lastValidatedAt: now
168
+ });
169
+ this.persistCache();
170
+ this.retry = 0;
171
+ this.nextCheck = now + import_headless.LICENSE_REVALIDATION_INTERVAL;
172
+ } catch (error) {
173
+ if (startedAt !== this.generation) return;
174
+ const delay6 = (0, import_headless.isTransientLicenseError)(error) ? import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++] : void 0;
175
+ this.nextCheck = Date.now() + (delay6 ?? import_headless.LICENSE_REVALIDATION_INTERVAL);
176
+ } finally {
177
+ this.busy = false;
178
+ this.notify();
179
+ }
180
+ }
181
+ persistCache() {
182
+ try {
183
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(this.cacheFile), { recursive: true, mode: 448 });
184
+ (0, import_node_fs.writeFileSync)(`${this.cacheFile}.tmp`, JSON.stringify(this.cache), { mode: 384 });
185
+ (0, import_node_fs.renameSync)(`${this.cacheFile}.tmp`, this.cacheFile);
186
+ } catch {
187
+ }
188
+ }
189
+ removeCache() {
190
+ try {
191
+ (0, import_node_fs.unlinkSync)(this.cacheFile);
192
+ } catch {
193
+ }
194
+ }
195
+ };
196
+
29
197
  // src/sync/SyncClient.ts
30
198
  var import_node_http = __toESM(require("http"));
31
- var import_node_crypto = require("crypto");
199
+ var import_node_crypto2 = require("crypto");
32
200
  var import_socket = require("socket.io-client");
33
201
  var import_sync_broker2 = require("@buoy-gg/sync-broker");
34
202
 
@@ -77,6 +245,9 @@ function diagnoseCrashed(opts) {
77
245
  var SyncClient = class {
78
246
  constructor(opts = {}) {
79
247
  this.socket = null;
248
+ this.admitted = false;
249
+ this.lockedDevices = /* @__PURE__ */ new Set();
250
+ this.hostAccount = null;
80
251
  this.broker = null;
81
252
  /** Latest capabilities announce per deviceId. */
82
253
  this.capabilities = /* @__PURE__ */ new Map();
@@ -125,7 +296,10 @@ var SyncClient = class {
125
296
  );
126
297
  }
127
298
  this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);
299
+ this.hostAccount = new NodeAccount(() => this.broker?.refreshAccess());
300
+ this.hostAccount.start();
128
301
  this.broker = (0, import_sync_broker2.createBroker)({
302
+ canUseDesktop: this.hostAccount.hasAccess,
129
303
  port: this.port,
130
304
  onError: (e) => this.log(`broker error: ${e.code ?? e.message}`)
131
305
  });
@@ -138,7 +312,7 @@ var SyncClient = class {
138
312
  return mode;
139
313
  }
140
314
  connectSocket() {
141
- return new Promise((resolve, reject) => {
315
+ return new Promise((resolve2, reject) => {
142
316
  const socket = (0, import_socket.io)(this.url, {
143
317
  transports: ["websocket"],
144
318
  query: { deviceName: DASHBOARD_NAME },
@@ -148,31 +322,59 @@ var SyncClient = class {
148
322
  });
149
323
  this.socket = socket;
150
324
  let settled = false;
151
- const onConnect = () => {
325
+ const onReady = () => {
152
326
  if (settled) return;
153
327
  settled = true;
154
328
  this.log("connected to broker");
155
- resolve();
329
+ resolve2();
156
330
  };
157
- socket.on("connect", onConnect);
158
- socket.on("connect", () => this.rearmWatches());
331
+ const readinessTimeout = setTimeout(() => {
332
+ if (settled) return;
333
+ settled = true;
334
+ reject(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."));
335
+ }, 5e3);
336
+ socket.on("buoy:access", (message) => {
337
+ const ready = !!message && typeof message === "object" && message.protocol === 1 && message.ready === true;
338
+ this.setAdmission(ready);
339
+ if (ready) {
340
+ clearTimeout(readinessTimeout);
341
+ this.rearmWatches();
342
+ onReady();
343
+ }
344
+ });
345
+ socket.on("connect", () => {
346
+ socket.emit("buoy:access:request");
347
+ });
348
+ socket.on("disconnect", () => this.setAdmission(false, true));
159
349
  socket.once("connect_error", (err) => {
160
350
  if (settled) return;
161
351
  settled = true;
352
+ clearTimeout(readinessTimeout);
162
353
  reject(new Error(`failed to connect to broker at ${this.url}: ${err.message}`));
163
354
  });
164
355
  socket.on("devtool-capabilities", (msg) => {
356
+ if (!this.admitted || this.lockedDevices.has(msg.persistentDeviceId)) return;
165
357
  this.capabilities.set(msg.persistentDeviceId, msg);
166
358
  void this.learnDevServerOrigin(msg.persistentDeviceId);
167
359
  });
168
360
  socket.on("all-devices-update", (list) => {
361
+ if (!this.admitted) return;
169
362
  if (!Array.isArray(list)) return;
363
+ let deviceReadmitted = false;
364
+ for (const device of list) {
365
+ if (device.deviceId && device.tier === "locked") this.lockDevice(String(device.deviceId));
366
+ else if (device.deviceId && (device.tier === "free" || device.tier === "pro")) {
367
+ deviceReadmitted = this.lockedDevices.delete(String(device.deviceId)) || deviceReadmitted;
368
+ }
369
+ }
170
370
  this.devices = list;
171
371
  const now = Date.now();
172
372
  for (const d of list) if (d.deviceId) this.lastSeen.set(d.deviceId, now);
173
373
  this.pruneStaleDevices();
374
+ if (deviceReadmitted) this.rearmWatches();
174
375
  });
175
376
  socket.on("devtool-sync", (msg) => {
377
+ if (!this.admitted || this.lockedDevices.has(msg.persistentDeviceId)) return;
176
378
  const key = snapKey(msg.persistentDeviceId, msg.toolId);
177
379
  this.snapshots.set(key, msg.payload);
178
380
  this.snapshotAt.set(key, Date.now());
@@ -183,6 +385,7 @@ var SyncClient = class {
183
385
  }
184
386
  });
185
387
  socket.on("devtool-action-result", (msg) => {
388
+ if (!this.admitted) return;
186
389
  const pending = this.pendingActions.get(msg.requestId);
187
390
  if (!pending) return;
188
391
  this.pendingActions.delete(msg.requestId);
@@ -200,6 +403,55 @@ var SyncClient = class {
200
403
  * with every simulator erase and fresh dev client, and each one can hold
201
404
  * multi-MB snapshots, so a long-lived server has to forget eventually.
202
405
  */
406
+ requireAdmission() {
407
+ if (!this.admitted) throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.");
408
+ }
409
+ setAdmission(ready, preserveWatchIntent = false) {
410
+ this.admitted = ready;
411
+ if (ready) return;
412
+ this.devices = [];
413
+ this.lockedDevices.clear();
414
+ this.capabilities.clear();
415
+ this.snapshots.clear();
416
+ this.snapshotAt.clear();
417
+ this.devServerOrigins.clear();
418
+ this.originProbed.clear();
419
+ this.health.clear();
420
+ this.lastSeen.clear();
421
+ if (!preserveWatchIntent) this.watching.clear();
422
+ for (const pending of this.pendingActions.values()) {
423
+ clearTimeout(pending.timer);
424
+ pending.reject(new Error("Buoy account access ended while the action was pending."));
425
+ }
426
+ this.pendingActions.clear();
427
+ for (const waiters of this.snapshotWaiters.values()) for (const waiter of waiters) waiter(void 0);
428
+ this.snapshotWaiters.clear();
429
+ if (this.socket) this.socket.sendBuffer = [];
430
+ }
431
+ requireDeviceAccount(deviceId) {
432
+ if (this.lockedDevices.has(deviceId)) throw new Error("The device's Buoy account access ended. Sign in and verify its key.");
433
+ }
434
+ lockDevice(deviceId) {
435
+ this.lockedDevices.add(deviceId);
436
+ this.capabilities.delete(deviceId);
437
+ this.health.delete(deviceId);
438
+ this.devServerOrigins.delete(deviceId);
439
+ this.originProbed.delete(deviceId);
440
+ const prefix = `${deviceId}::`;
441
+ for (const key of this.snapshots.keys()) if (key.startsWith(prefix)) {
442
+ this.snapshots.delete(key);
443
+ this.snapshotAt.delete(key);
444
+ }
445
+ for (const [key, waiters] of this.snapshotWaiters) if (key.startsWith(prefix)) {
446
+ this.snapshotWaiters.delete(key);
447
+ for (const waiter of waiters) waiter(void 0);
448
+ }
449
+ for (const [id, pending] of this.pendingActions) if (pending.target === deviceId || pending.target === ALL_DEVICES) {
450
+ clearTimeout(pending.timer);
451
+ this.pendingActions.delete(id);
452
+ pending.reject(new Error("Device account access ended while the action was pending."));
453
+ }
454
+ }
203
455
  pruneStaleDevices() {
204
456
  const cutoff = Date.now() - DEVICE_RETENTION_MS;
205
457
  for (const [deviceId, seen] of this.lastSeen) {
@@ -224,6 +476,9 @@ var SyncClient = class {
224
476
  }
225
477
  /** Disconnect and tear down a spawned broker, if any. */
226
478
  async stop() {
479
+ this.setAdmission(false);
480
+ this.hostAccount?.stop();
481
+ this.hostAccount = null;
227
482
  for (const [, p] of this.pendingActions) {
228
483
  clearTimeout(p.timer);
229
484
  p.reject(new Error("SyncClient stopped"));
@@ -243,6 +498,7 @@ var SyncClient = class {
243
498
  // ----------------------------------------------------------------
244
499
  /** All devices the broker knows about, merged with their advertised tools. */
245
500
  listDevices() {
501
+ if (!this.admitted) return [];
246
502
  return this.devices.filter((d) => d.deviceName !== DASHBOARD_NAME && d.deviceId).map((d) => {
247
503
  const caps = this.capabilities.get(d.deviceId);
248
504
  return {
@@ -251,7 +507,7 @@ var SyncClient = class {
251
507
  platform: d.platform,
252
508
  isConnected: d.isConnected !== false,
253
509
  isPro: d.isPro === true,
254
- tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
510
+ tier: d.tier === "free" || d.tier === "pro" ? d.tier : d.tier === void 0 && d.isPro === true ? "pro" : "locked",
255
511
  collision: d.collision === true,
256
512
  tools: caps?.tools ?? []
257
513
  };
@@ -274,9 +530,19 @@ var SyncClient = class {
274
530
  * or many, an informative error is thrown (callers can pass "All" explicitly).
275
531
  */
276
532
  resolveDevice(deviceId) {
277
- if (deviceId) return deviceId;
278
- const connected = this.connectedDevices();
533
+ this.requireAdmission();
534
+ if (deviceId) {
535
+ this.requireDeviceAccount(deviceId);
536
+ return deviceId;
537
+ }
538
+ const allConnected = this.connectedDevices();
539
+ const connected = allConnected.filter((d) => !this.lockedDevices.has(d.deviceId));
279
540
  if (connected.length === 0) {
541
+ if (allConnected.length > 0) {
542
+ throw new Error(
543
+ `Devices are connected (${allConnected.map((d) => d.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`
544
+ );
545
+ }
280
546
  throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.");
281
547
  }
282
548
  if (connected.length === 1) return connected[0].deviceId;
@@ -302,9 +568,10 @@ var SyncClient = class {
302
568
  * is blocked.
303
569
  */
304
570
  requireProDevice(deviceId) {
571
+ this.requireAdmission();
305
572
  const tierNote = (dev2) => {
306
573
  if (!dev2) return "";
307
- return dev2.tier === "anonymous" ? ` The connected app "${dev2.deviceName}" has no license key at all.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
574
+ return dev2.tier === "locked" ? ` The broker has not confirmed account access for "${dev2.deviceName}"; a locked status does not establish whether a key is configured.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
308
575
  };
309
576
  const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
310
577
  dev2
@@ -419,6 +686,7 @@ var SyncClient = class {
419
686
  }
420
687
  /** Age of the cached snapshot for a tool, in ms (null = never received). */
421
688
  snapshotAgeMs(toolId, deviceId) {
689
+ this.requireAdmission();
422
690
  const at = this.snapshotAt.get(snapKey(deviceId, toolId));
423
691
  return at ? Date.now() - at : null;
424
692
  }
@@ -454,6 +722,7 @@ var SyncClient = class {
454
722
  }
455
723
  /** The dev-server origin last reported by this device, if any. */
456
724
  devServerOrigin(deviceId) {
725
+ this.requireAdmission();
457
726
  const known = this.devServerOrigins.get(deviceId);
458
727
  if (known) return known;
459
728
  const snap = this.snapshots.get(snapKey(deviceId, "app"));
@@ -487,6 +756,7 @@ var SyncClient = class {
487
756
  this.sendWatch(toolId, targetDeviceId);
488
757
  }
489
758
  sendWatch(toolId, targetDeviceId) {
759
+ if (!this.admitted || !this.socket?.connected || this.lockedDevices.has(targetDeviceId)) return;
490
760
  this.socket?.emit("devtool-watch", {
491
761
  toolId,
492
762
  targetDeviceId,
@@ -512,10 +782,12 @@ var SyncClient = class {
512
782
  unwatch(toolId, targetDeviceId) {
513
783
  const key = snapKey(targetDeviceId, toolId);
514
784
  if (!this.watching.delete(key)) return;
515
- this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: false });
785
+ if (this.admitted && this.socket?.connected) this.socket.emit("devtool-watch", { toolId, targetDeviceId, watching: false });
516
786
  }
517
787
  /** Last snapshot received for a tool/device, if any. */
518
788
  cachedSnapshot(toolId, deviceId) {
789
+ this.requireAdmission();
790
+ this.requireDeviceAccount(deviceId);
519
791
  return this.snapshots.get(snapKey(deviceId, toolId));
520
792
  }
521
793
  /**
@@ -543,6 +815,8 @@ var SyncClient = class {
543
815
  const next = this.waitForSnapshot(key, budget);
544
816
  this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
545
817
  const payload = await next;
818
+ this.requireAdmission();
819
+ this.requireDeviceAccount(target);
546
820
  if (payload !== void 0) {
547
821
  if (!opts.silent) this.noteOk(target);
548
822
  return { deviceId: target, payload };
@@ -563,16 +837,16 @@ var SyncClient = class {
563
837
  return `\u26A0\uFE0F Served from the last sync (${ageText}) \u2014 ${this.unresponsiveNote(deviceId)}`;
564
838
  }
565
839
  waitForSnapshot(key, timeoutMs) {
566
- return new Promise((resolve) => {
840
+ return new Promise((resolve2) => {
567
841
  const waiters = this.snapshotWaiters.get(key) ?? [];
568
842
  const timer = setTimeout(() => {
569
843
  const remaining = (this.snapshotWaiters.get(key) ?? []).filter((w) => w !== onSnap);
570
844
  this.snapshotWaiters.set(key, remaining);
571
- resolve(void 0);
845
+ resolve2(void 0);
572
846
  }, timeoutMs);
573
847
  const onSnap = (payload) => {
574
848
  clearTimeout(timer);
575
- resolve(payload);
849
+ resolve2(payload);
576
850
  };
577
851
  waiters.push(onSnap);
578
852
  this.snapshotWaiters.set(key, waiters);
@@ -586,11 +860,12 @@ var SyncClient = class {
586
860
  * target one device, or "All" to broadcast (resolves with the first responder).
587
861
  */
588
862
  async callAction(toolId, action, params, deviceId, opts = {}) {
863
+ this.requireAdmission();
589
864
  const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
590
865
  if (!this.socket) throw new Error("SyncClient is not connected");
591
- const requestId = (0, import_node_crypto.randomUUID)();
866
+ const requestId = (0, import_node_crypto2.randomUUID)();
592
867
  const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target, toolId, action);
593
- const result = await new Promise((resolve, reject) => {
868
+ const result = await new Promise((resolve2, reject) => {
594
869
  const timer = setTimeout(() => {
595
870
  this.pendingActions.delete(requestId);
596
871
  if (target !== ALL_DEVICES && !opts.silent) this.noteTimeout(target);
@@ -600,7 +875,7 @@ var SyncClient = class {
600
875
  )
601
876
  );
602
877
  }, timeoutMs);
603
- this.pendingActions.set(requestId, { resolve, reject, timer });
878
+ this.pendingActions.set(requestId, { resolve: resolve2, reject, timer, target });
604
879
  this.socket.emit("devtool-action", {
605
880
  requestId,
606
881
  toolId,
@@ -618,16 +893,16 @@ var SyncClient = class {
618
893
  };
619
894
  var snapKey = (deviceId, toolId) => `${deviceId}::${toolId}`;
620
895
  function probeBroker(url, timeoutMs = 800) {
621
- return new Promise((resolve) => {
896
+ return new Promise((resolve2) => {
622
897
  const req = import_node_http.default.get(url, (res) => {
623
898
  res.resume();
624
- resolve((res.statusCode ?? 0) > 0);
899
+ resolve2((res.statusCode ?? 0) > 0);
625
900
  });
626
901
  req.setTimeout(timeoutMs, () => {
627
902
  req.destroy();
628
- resolve(false);
903
+ resolve2(false);
629
904
  });
630
- req.on("error", () => resolve(false));
905
+ req.on("error", () => resolve2(false));
631
906
  });
632
907
  }
633
908
  async function waitForBroker(url, attempts = 20) {
@@ -647,19 +922,19 @@ var import_zod26 = require("zod");
647
922
  var import_zod = require("zod");
648
923
 
649
924
  // src/update/checkForUpdate.ts
650
- var import_node_fs = require("fs");
651
- var import_node_os = __toESM(require("os"));
652
- var import_node_path = __toESM(require("path"));
925
+ var import_node_fs2 = require("fs");
926
+ var import_node_os2 = __toESM(require("os"));
927
+ var import_node_path2 = __toESM(require("path"));
653
928
 
654
929
  // src/version.ts
655
- var VERSION = "7.0.36" ? "7.0.36" : "0.0.0-dev";
930
+ var VERSION = "7.0.38" ? "7.0.38" : "0.0.0-dev";
656
931
  var PACKAGE_NAME = "@buoy-gg/mcp";
657
932
 
658
933
  // src/update/checkForUpdate.ts
659
934
  var CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
660
935
  var FETCH_TIMEOUT_MS = 1500;
661
936
  var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
662
- var CACHE_FILE = import_node_path.default.join(import_node_os.default.tmpdir(), "buoy-mcp-update-check.json");
937
+ var CACHE_FILE = import_node_path2.default.join(import_node_os2.default.tmpdir(), "buoy-mcp-update-check.json");
663
938
  function isNewer(a, b) {
664
939
  const parse = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
665
940
  const pa = parse(a);
@@ -674,7 +949,7 @@ function isNewer(a, b) {
674
949
  }
675
950
  async function readCache() {
676
951
  try {
677
- const raw = await import_node_fs.promises.readFile(CACHE_FILE, "utf8");
952
+ const raw = await import_node_fs2.promises.readFile(CACHE_FILE, "utf8");
678
953
  const parsed = JSON.parse(raw);
679
954
  if (typeof parsed.checkedAt === "number" && typeof parsed.latest === "string") {
680
955
  return parsed;
@@ -692,7 +967,7 @@ async function fetchLatest(now) {
692
967
  const body = await res.json();
693
968
  const latest = typeof body.version === "string" ? body.version : null;
694
969
  if (latest) {
695
- await import_node_fs.promises.writeFile(CACHE_FILE, JSON.stringify({ checkedAt: now, latest })).catch(() => {
970
+ await import_node_fs2.promises.writeFile(CACHE_FILE, JSON.stringify({ checkedAt: now, latest })).catch(() => {
696
971
  });
697
972
  }
698
973
  return latest;
@@ -745,7 +1020,7 @@ function registerGenericTools(server, sync) {
745
1020
  }
746
1021
  const connected = devices.filter((d) => d.isConnected);
747
1022
  const anyPro = connected.some((d) => d.isPro);
748
- const anySignedIn = connected.some((d) => d.tier !== "anonymous");
1023
+ const anySignedIn = connected.some((d) => d.tier !== "locked");
749
1024
  const collided = connected.filter((d) => d.collision);
750
1025
  return json({
751
1026
  devices,
@@ -755,7 +1030,7 @@ function registerGenericTools(server, sync) {
755
1030
  ...anyPro ? {} : {
756
1031
  // A headless build has no on-device UI, so "upgrade in the app"
757
1032
  // cannot be followed there — name the `licenseKey` prop too.
758
- hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless." : "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Enter a license in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless (no on-device UI)."
1033
+ hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless." : "The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."
759
1034
  },
760
1035
  ...updateNotice ? { updateAvailable: updateNotice } : {}
761
1036
  });
@@ -1110,7 +1385,7 @@ function formatRankingMarkdown(ranking) {
1110
1385
  ].filter(Boolean).join(" \xB7 ");
1111
1386
  if (leaderLine) lines.push(`**Per-metric best:** ${leaderLine}`);
1112
1387
  lines.push(
1113
- ranking.balanced.length ? `**Balanced (caps nothing):** ${ranking.balanced.join(", ")}` : `**Balanced (caps nothing):** none \u2014 every case stressed at least one axis; pick by which trade-off you can accept.`
1388
+ ranking.balanced.length ? `**No configured risk flags:** ${ranking.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.` : `**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`
1114
1389
  );
1115
1390
  if (ranking.flagged.length) {
1116
1391
  lines.push(
@@ -1297,7 +1572,7 @@ function registerPerfMonitorTools(server, sync) {
1297
1572
  server.registerTool(
1298
1573
  "run_benchmark_batch",
1299
1574
  {
1300
- description: "Run a perf-monitor benchmark batch and return a RANKED comparison of all cases (winner by UI FPS). Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",
1575
+ description: "Run a perf-monitor benchmark batch and return per-metric leaders, risk flags and a comparison of available case results. Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",
1301
1576
  inputSchema: {
1302
1577
  targetRoute: import_zod3.z.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),
1303
1578
  cases: import_zod3.z.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),
@@ -1308,10 +1583,10 @@ function registerPerfMonitorTools(server, sync) {
1308
1583
  coolDownMs: import_zod3.z.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),
1309
1584
  navTimeoutMs: import_zod3.z.number().optional().describe("Max wait for navigation per run. Default 5000."),
1310
1585
  discardWarmupRuns: import_zod3.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
1311
- shuffleCases: import_zod3.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
1586
+ shuffleCases: import_zod3.z.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),
1312
1587
  reloadBetweenCases: import_zod3.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
1313
1588
  captureRenders: import_zod3.z.boolean().optional().describe(
1314
- "Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds slight JS-thread overhead applied equally to every case. Default true."
1589
+ "Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds instrumentation overhead that can vary with the workload. Default true."
1315
1590
  ),
1316
1591
  deviceId: import_zod3.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
1317
1592
  maxWaitMs: import_zod3.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
@@ -1505,7 +1780,7 @@ ${JSON.stringify(ranking, null, 2)}
1505
1780
  server.registerTool(
1506
1781
  "compare_reports",
1507
1782
  {
1508
- 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.",
1783
+ 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 comparable workloads and inspect available metrics together. A wasted-render delta alone does not prove a fix.",
1509
1784
  inputSchema: {
1510
1785
  reportIdA: import_zod3.z.string().describe("First (baseline) report id."),
1511
1786
  reportIdB: import_zod3.z.string().describe("Second report id to compare against the baseline."),
@@ -1574,7 +1849,7 @@ ${JSON.stringify(settings, null, 2)}
1574
1849
  navTimeoutMs: import_zod3.z.number().optional().describe("Max wait for navigation per run (ms)."),
1575
1850
  runsPerCase: import_zod3.z.number().optional().describe("Runs per page; median is the canonical result (1\u201310)."),
1576
1851
  discardWarmupRuns: import_zod3.z.number().optional().describe("Discard first N runs of each page before the median."),
1577
- shuffleCases: import_zod3.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
1852
+ shuffleCases: import_zod3.z.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias."),
1578
1853
  reloadBetweenCases: import_zod3.z.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
1579
1854
  captureRenders: import_zod3.z.boolean().optional().describe("Capture per-component render counts/durations during each run."),
1580
1855
  deviceId: deviceIdInput
@@ -1639,7 +1914,7 @@ function registerEventsTools(server, sync) {
1639
1914
  server.registerTool(
1640
1915
  "get_events",
1641
1916
  {
1642
- 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.",
1917
+ description: "Read retained events from connected Buoy sources. Filter with sources, such as ['network'], ['redux'], ['react-query'], ['storage'] or ['route']. Returns compact summaries by default; use includeData:true for available payload details. Coverage depends on integrations and retention. An empty timeline does not prove an event never happened. Use get_network_requests for request IDs and get_snapshot for available current tool state.",
1643
1918
  inputSchema: {
1644
1919
  limit: import_zod4.z.number().optional().describe("Most-recent N events to include. Default 25."),
1645
1920
  sources: import_zod4.z.array(import_zod4.z.string()).optional().describe('Only these sources. Coarse names accepted: "network", "redux", "react-query", "storage", "route", "render", "zustand", "jotai". Omit for all.'),
@@ -1768,12 +2043,12 @@ async function captureDiagnosis(sync, target) {
1768
2043
  target
1769
2044
  );
1770
2045
  } catch {
1771
- return "The network tool has captured nothing yet.";
2046
+ return "Capture status is unavailable. An empty result does not establish whether requests occurred.";
1772
2047
  }
1773
2048
  if (!status || status.capturing !== false) {
1774
- return "The device IS capturing, so nothing has been requested since it started.";
2049
+ return "No requests are available in this result. Check capture setup, supported transports and hook timing before concluding that no request occurred.";
1775
2050
  }
1776
- return "The device is NOT capturing. Reading from here arms capture, so the very next read should see traffic \u2014 trigger it and call again. This is not an app or interception problem. (If it stays off, the device is on a Buoy older than the capture-mode watch: open the Network panel on the device or in Buoy Desktop to record.)";
2051
+ return "Capture is disabled. Open the Network panel on the device or in Buoy Desktop, trigger a supported request and read again. An empty result does not establish whether requests occurred. Capture depends on installed hooks, supported transports and recording timing.";
1777
2052
  }
1778
2053
  function skippedNote(skipped) {
1779
2054
  return skipped > 0 ? `
@@ -1805,15 +2080,33 @@ function formatRow(index, e, markers) {
1805
2080
  \u2514\u2500 ${bits.join(" \xB7 ")}`;
1806
2081
  }
1807
2082
  function registerNetworkTools(server, sync) {
2083
+ server.registerTool("network_conditions", {
2084
+ description: "Read or set the device's network conditions. Offline blocks new intercepted HTTP requests; slow adds 500 ms and verySlow adds 2000 ms before dispatch. Normal clears conditions. State resets on JS reload. Check app and server observations to verify the effect.",
2085
+ inputSchema: {
2086
+ action: import_zod5.z.enum(["get", "set"]).describe("Read the effective profile or select one."),
2087
+ profile: import_zod5.z.enum(["normal", "offline", "slow", "verySlow"]).optional().describe("Required for set."),
2088
+ deviceId: deviceIdInput
2089
+ }
2090
+ }, async (args) => {
2091
+ try {
2092
+ if (args.action === "set" && !args.profile) return fail2("Set requires a profile.");
2093
+ const target = sync.resolveDevice(args.deviceId);
2094
+ const result = await sync.callAction(NETWORK_TOOL_ID, args.action === "get" ? "getNetworkConditions" : "setNetworkConditions", args.action === "set" ? { profile: args.profile } : void 0, target);
2095
+ if (!result || typeof result !== "object" || !("profile" in result)) return fail2("The device did not report its effective network conditions.");
2096
+ return text(JSON.stringify(result));
2097
+ } catch (error) {
2098
+ return fail2(errMsg2(error));
2099
+ }
2100
+ });
1808
2101
  server.registerTool(
1809
2102
  "get_network_requests",
1810
2103
  {
1811
- 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).",
2104
+ description: "List captured HTTP requests with IDs, available status/timing/size fields and pinned or saved markers. Capture depends on supported transports, installed hooks and recording timing; requests made before capture starts may be absent. Use IDs for network actions and flagged to inspect retained records. A flag does not establish who created it or the cause of a problem. Filter by status or URL pattern. includeBodies:true requests available truncated bodies, which can be missing or withheld. Requires the network integration and Buoy Pro.",
1812
2105
  inputSchema: {
1813
2106
  limit: import_zod5.z.number().optional().describe("Most-recent N requests. Default 25."),
1814
2107
  pattern: import_zod5.z.string().optional().describe("Only requests whose URL contains this substring (case-insensitive)."),
1815
2108
  status: import_zod5.z.enum(["all", "errors", "success", "pending"]).optional().describe("Filter by outcome. Default all."),
1816
- flagged: import_zod5.z.enum(["pinned", "saved", "any"]).optional().describe("Read KEPT requests instead of the live list: 'pinned' (held at the top of the developer's list), 'saved' (their favorites), 'any' (either). These survive Clear and app restarts."),
2109
+ flagged: import_zod5.z.enum(["pinned", "saved", "any"]).optional().describe("Read KEPT requests instead of the live list: 'pinned' (held at the top of the developer's list), 'saved' (their favorites), 'any' (either). These are separate from the live list; restart retention depends on successful persistence."),
1817
2110
  includeBodies: import_zod5.z.boolean().optional().describe("Include truncated request/response bodies. Default false (compact)."),
1818
2111
  deviceId: deviceIdInput
1819
2112
  }
@@ -1892,7 +2185,7 @@ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_act
1892
2185
  server.registerTool(
1893
2186
  "network_action",
1894
2187
  {
1895
- description: "Act on captured HTTP requests: PIN one (holds it at the top of the developer's Network list, ignoring their filters), SAVE one to their favorites, or clear the list. Pinning and saving keep a full snapshot that survives Clear, the 500-request cap and app restarts \u2014 so this is how you hand a request to a human ('I pinned the failing call') or keep one you'll need after the log rolls over. Get ids from get_network_requests (get_events does not emit them). `remove` takes the record `key` from get_network_requests({flagged:'any'}), not a request id.",
2188
+ description: "Pin or save captured requests as retained records, or remove records and clear lists. Bodies may be truncated or omitted by storage budgets. Records are kept separately from the live list; availability after restart depends on successful device persistence. Get request IDs from get_network_requests. remove uses a retained-record key from flagged results. An action acknowledgement does not confirm a durable storage write.",
1896
2189
  inputSchema: {
1897
2190
  action: import_zod5.z.enum(["pin", "unpin", "save", "unsave", "remove", "clearPinned", "clearSaved", "clearRequests"]).describe("pin/unpin/save/unsave act on one request by `id`. remove drops one kept record by `key`. clearPinned/clearSaved drop all of that kind. clearRequests wipes the live capture list (kept requests survive)."),
1898
2191
  id: import_zod5.z.string().optional().describe("Request id for pin/unpin/save/unsave \u2014 from get_network_requests."),
@@ -1948,7 +2241,7 @@ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_act
1948
2241
  return text(`Cleared saved requests on ${target} (pinned ones kept).`);
1949
2242
  case "clearRequests":
1950
2243
  await sync.callAction(NETWORK_TOOL_ID, "clearEvents", {}, target);
1951
- return text(`Cleared the captured request list on ${target}. Pinned/saved requests are unaffected.`);
2244
+ return text(`Clear request sent to ${target}. Re-list to confirm the live capture list; this action does not clear retained records.`);
1952
2245
  }
1953
2246
  } catch (e) {
1954
2247
  return fail2(errMsg2(e));
@@ -1958,7 +2251,7 @@ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_act
1958
2251
  server.registerTool(
1959
2252
  "network_override",
1960
2253
  {
1961
- description: "Force matching HTTP requests to return a chosen status/body, to fail outright, or to arrive late \u2014 without touching the backend or the app's code. This is how you drive an app into a state it won't reach on its own: force a 500 and see whether the error UI appears, return [] and check the empty state, simulate offline or a slow connection. Rules run INSIDE the app and survive an app reload, so they also cover the boot path. After adding one, trigger the request (navigate/tap_element) and read the result with get_network_requests or describe_screen. ALWAYS remove your rules when done \u2014 a forgotten rule looks exactly like a real bug. Requires Buoy Pro.",
2254
+ description: "Configure response, failure or delay rules for supported requests intercepted by the app integration. Coverage depends on transport, hooks and rule-loading timing; early startup requests may bypass a rule. Inspect existing rules before editing, trigger the intended request and check observed results. Simulated offline behavior does not disconnect the device. Remove rules created for this task afterward or restore prior settings; preserve unrelated rules. Requires Buoy Pro and network override support.",
1962
2255
  inputSchema: {
1963
2256
  action: import_zod5.z.enum(["list", "add", "remove", "clear", "enable", "disable", "debug"]).describe(
1964
2257
  "add creates a rule (or replaces one when `id` is given). remove drops one by `id`. clear drops all. enable/disable flip ONE rule when `id` is given, otherwise the master switch \u2014 rules are kept either way. list shows every rule with its hit counts; pass `id` to dump one rule in full, body included. debug reports why a rule is or isn't firing."
@@ -1996,7 +2289,7 @@ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_act
1996
2289
  ),
1997
2290
  name: import_zod5.z.string().optional().describe("Label shown in the developer's UI."),
1998
2291
  enabled: import_zod5.z.boolean().optional().describe(
1999
- "For `add`: create the rule switched off (default true). Useful for staging a rule you'll arm later with enable({id})."
2292
+ "For `add`: whether the rule is enabled. Default true; pass false to stage it switched off. Useful for staging a rule you'll arm later with enable({id})."
2000
2293
  ),
2001
2294
  id: import_zod5.z.string().optional().describe(
2002
2295
  "Rule id. Required for `remove`. For `add` it replaces that rule. For `enable`/`disable` it targets ONE rule instead of the master switch. For `list` it dumps that rule in full."
@@ -2067,7 +2360,7 @@ Trigger the request now (navigate / tap_element), then check it with get_network
2067
2360
  }
2068
2361
  case "clear":
2069
2362
  await sync.callAction(NETWORK_TOOL_ID, "clearOverrideRules", {}, target);
2070
- return text(`Removed all override rules on ${target}. Traffic is real again.`);
2363
+ return text(`Removed all override rules on ${target}. Other interception or simulation settings may still apply.`);
2071
2364
  case "enable":
2072
2365
  case "disable": {
2073
2366
  const enabled = args.action === "enable";
@@ -2079,12 +2372,12 @@ Trigger the request now (navigate / tap_element), then check it with get_network
2079
2372
  target
2080
2373
  );
2081
2374
  return text(
2082
- `Rule \`${args.id}\` is now ${enabled ? "ON" : "OFF"} on ${target}. The other rules and the master switch are unchanged.`
2375
+ `Rule \`${args.id}\` was requested ${enabled ? "ON" : "OFF"} on ${target}. The other rules and the master switch are unchanged.`
2083
2376
  );
2084
2377
  }
2085
2378
  await sync.callAction(NETWORK_TOOL_ID, "setOverridesEnabled", { enabled }, target);
2086
2379
  return text(
2087
- enabled ? `Overrides switched ON for ${target}.` : `Overrides switched OFF for ${target}. Rules are kept \u2014 re-enable with network_override({action:"enable"}).`
2380
+ enabled ? `Overrides enabled in settings for ${target}.` : `Overrides switched OFF for ${target}. Rules are kept \u2014 re-enable with network_override({action:"enable"}).`
2088
2381
  );
2089
2382
  }
2090
2383
  case "list": {
@@ -2114,7 +2407,7 @@ ${JSON.stringify(rule, null, 2)}
2114
2407
  return text(
2115
2408
  `No override rules on ${target}.
2116
2409
 
2117
- Add one with network_override({action:"add", urlPattern:"*", status:500}) to force an error state.`
2410
+ Add one with network_override({action:"add", urlPattern:"https://api.example.com/test*", status:500}) to force an error state.`
2118
2411
  );
2119
2412
  }
2120
2413
  const rows = rules.map((rule, i) => {
@@ -2211,7 +2504,7 @@ function registerReactQueryTools(server, sync) {
2211
2504
  server.registerTool(
2212
2505
  "get_react_query",
2213
2506
  {
2214
- description: "Read the app's CURRENT React Query cache \u2014 every query with its status (fresh/stale/fetching/error), staleness, observer count, last-updated time, and any error. Compact by DEFAULT (no cached payloads) so it's token-cheap; pass includeData:true for the actual cached data, or staleOnly:true to see just stale queries. Use this for 'what's in the cache / why is this stale / which queries errored'. (For the history of query updates over time, use get_events with sources:['react-query'].) Each row's hash is what you pass to react_query_action.",
2507
+ description: "Read queries reported by the connected React Query integration, including available status, observer counts, timestamps and errors. includeData:true requests cached payloads; staleOnly:true filters stale queries. Use each returned hash with react_query_action. History depends on retained events.",
2215
2508
  inputSchema: {
2216
2509
  includeData: import_zod6.z.boolean().optional().describe("Include each query's full cached data payload (HEAVY). Default false."),
2217
2510
  staleOnly: import_zod6.z.boolean().optional().describe("Only stale queries. Default false."),
@@ -2270,13 +2563,13 @@ ${lines.join("\n")}${note}`);
2270
2563
  const target = sync.requireProDevice(args.deviceId);
2271
2564
  if (args.action === "clear") {
2272
2565
  await callQueryAction(sync, "clearQueryCache", {}, target);
2273
- return text("Cleared the entire React Query cache.");
2566
+ return text("Requested clearing of the connected React Query cache. Read it again to confirm.");
2274
2567
  }
2275
2568
  if (!args.queryHash) {
2276
2569
  return fail2(`'${args.action}' needs a queryHash. Call get_react_query first and use a row's hash (in backticks).`);
2277
2570
  }
2278
2571
  await callQueryAction(sync, args.action, { queryHash: args.queryHash }, target);
2279
- return text(`Ran ${args.action} on query \`${args.queryHash}\`.`);
2572
+ return text(`Requested ${args.action} on query \`${args.queryHash}\`.`);
2280
2573
  } catch (e) {
2281
2574
  return fail2(errMsg2(e));
2282
2575
  }
@@ -2291,7 +2584,7 @@ function registerZustandTools(server, sync) {
2291
2584
  server.registerTool(
2292
2585
  "get_zustand_state",
2293
2586
  {
2294
- description: "Read the app's CURRENT Zustand store state \u2014 each registered store with its name, change count, persistence, and (by default) just its top-level keys so it's token-cheap. Pass includeValues:true for the full state objects. Use this for 'what's in my zustand stores / what's the current count'. (For the history of state changes over time, use get_events with sources:['zustand'].)",
2587
+ description: 'Read registered Zustand stores, available state keys, change counts and persistence metadata. includeValues:true requests values. Use get_events with sources:["zustand"] for retained changes.',
2295
2588
  inputSchema: {
2296
2589
  includeValues: import_zod7.z.boolean().optional().describe("Include each store's full current state object (HEAVY). Default false (keys only)."),
2297
2590
  limit: import_zod7.z.number().optional().describe("First N stores. Default all."),
@@ -2343,7 +2636,7 @@ ${lines.join("\n")}${note}`);
2343
2636
  { storeName: args.store, state: args.state, replace: args.replace ?? true },
2344
2637
  target
2345
2638
  );
2346
- return text(`Set state on Zustand store "${args.store}" (${args.replace === false ? "merged" : "replaced"}).`);
2639
+ return text(`Requested state update on Zustand store "${args.store}" (${args.replace === false ? "merged" : "replaced"}).`);
2347
2640
  })
2348
2641
  );
2349
2642
  }
@@ -2355,7 +2648,7 @@ function registerJotaiTools(server, sync) {
2355
2648
  server.registerTool(
2356
2649
  "get_jotai_state",
2357
2650
  {
2358
- description: "Read the app's CURRENT Jotai atom values \u2014 each watched atom with its label, change count, and whether it's writable. Compact by DEFAULT (no values) so it's token-cheap; pass includeValues:true for the actual current values. Use this for 'what are my jotai atoms / what's the current value'. (For the history of atom changes over time, use get_events with sources:['jotai'].)",
2651
+ description: 'Read watched Jotai atoms and their available labels, change counts and writable status. includeValues:true requests values. Use get_events with sources:["jotai"] for retained changes.',
2359
2652
  inputSchema: {
2360
2653
  includeValues: import_zod8.z.boolean().optional().describe("Include each atom's current value (HEAVY). Default false."),
2361
2654
  limit: import_zod8.z.number().optional().describe("First N atoms. Default all."),
@@ -2401,7 +2694,7 @@ ${lines.join("\n")}${note}`);
2401
2694
  },
2402
2695
  withDevice(sync, async (args, target) => {
2403
2696
  await sync.callAction(JOTAI_TOOL_ID, "setAtom", { label: args.atom, value: args.value }, target);
2404
- return text(`Set Jotai atom "${args.atom}" to ${JSON.stringify(args.value)}.`);
2697
+ return text(`Requested update of Jotai atom "${args.atom}" to ${JSON.stringify(args.value)}.`);
2405
2698
  })
2406
2699
  );
2407
2700
  }
@@ -2425,7 +2718,7 @@ function registerReduxTools(server, sync) {
2425
2718
  server.registerTool(
2426
2719
  "get_redux_state",
2427
2720
  {
2428
- description: "Read the app's CURRENT Redux state. Compact by DEFAULT \u2014 just the top-level slice names \u2014 so it's token-cheap; pass includeValues:true for the full state tree. Use this for 'what's in my redux store / current auth state'. (For the history of dispatched actions over time, use get_events with sources:['redux'].)",
2721
+ description: 'Read available state from the connected Redux integration. Lists top-level slices by default; includeValues:true requests state values. Use get_events with sources:["redux"] for retained action history.',
2429
2722
  inputSchema: {
2430
2723
  includeValues: import_zod9.z.boolean().optional().describe("Include the full current state tree (HEAVY). Default false (slice names only)."),
2431
2724
  deviceId: deviceIdInput
@@ -2574,9 +2867,9 @@ ${lines.join("\n")}`);
2574
2867
  server.registerTool(
2575
2868
  "storage_action",
2576
2869
  {
2577
- description: "Modify the app's AsyncStorage: set a key, remove a key, or clear all app storage (Buoy's own devtool keys are preserved). Values are stored as strings \u2014 JSON-encode objects yourself.",
2870
+ description: "Modify the app's AsyncStorage: set a key, remove a key, or clear supported app AsyncStorage keys (Buoy's own devtool keys are preserved). Values are stored as strings \u2014 JSON-encode objects yourself.",
2578
2871
  inputSchema: {
2579
- action: import_zod10.z.enum(["set", "remove", "clear"]).describe("set/remove a key, or clear all app storage."),
2872
+ action: import_zod10.z.enum(["set", "remove", "clear"]).describe("set/remove a key, or clear supported app AsyncStorage keys."),
2580
2873
  key: import_zod10.z.string().optional().describe("The key (required for set and remove)."),
2581
2874
  value: import_zod10.z.string().optional().describe("The string value to store (required for set; JSON-encode objects)."),
2582
2875
  deviceId: deviceIdInput
@@ -2587,20 +2880,20 @@ ${lines.join("\n")}`);
2587
2880
  const target = sync.requireProDevice(args.deviceId);
2588
2881
  if (args.action === "clear") {
2589
2882
  await sync.callAction(STORAGE_TOOL_ID, "clearAppStorage", {}, target);
2590
- return text("Cleared all app storage (Buoy's own devtool keys preserved).");
2883
+ return text("Requested clearing of supported app AsyncStorage keys. Read storage again to confirm.");
2591
2884
  }
2592
2885
  if (!args.key) {
2593
2886
  return fail2(`'${args.action}' needs a key.`);
2594
2887
  }
2595
2888
  if (args.action === "remove") {
2596
2889
  await sync.callAction(STORAGE_TOOL_ID, "async.removeItem", { key: args.key }, target);
2597
- return text(`Removed storage key "${args.key}".`);
2890
+ return text(`Requested removal of storage key "${args.key}".`);
2598
2891
  }
2599
2892
  if (args.value === void 0) {
2600
2893
  return fail2("'set' needs a value (a string).");
2601
2894
  }
2602
2895
  await sync.callAction(STORAGE_TOOL_ID, "async.setItem", { key: args.key, value: args.value }, target);
2603
- return text(`Set storage key "${args.key}".`);
2896
+ return text(`Requested update of storage key "${args.key}".`);
2604
2897
  } catch (e) {
2605
2898
  return fail2(errMsg2(e));
2606
2899
  }
@@ -2622,9 +2915,9 @@ function registerRoutesTools(server, sync) {
2622
2915
  server.registerTool(
2623
2916
  "get_routes",
2624
2917
  {
2625
- description: "Read the app's CURRENT route + navigation stack (where the user is and how they got there). Compact by DEFAULT; pass includeSitemap:true to also list every route path the app defines (useful before navigate). Use for 'what screen am I on / what's the nav stack / what routes exist'. (For navigation history over time, use get_events with sources:['route'].)",
2918
+ description: 'Read reported current navigation state and stack. includeSitemap:true includes available configured route paths; it does not establish complete application route coverage. Use get_events with sources:["route"] for retained navigation history.',
2626
2919
  inputSchema: {
2627
- includeSitemap: import_zod11.z.boolean().optional().describe("Also list all defined route paths (can be long). Default false."),
2920
+ includeSitemap: import_zod11.z.boolean().optional().describe("Also list available configured route paths (can be long). Default false."),
2628
2921
  deviceId: deviceIdInput
2629
2922
  }
2630
2923
  },
@@ -2705,7 +2998,7 @@ function registerConsoleTools(server, sync) {
2705
2998
  server.registerTool(
2706
2999
  "get_console",
2707
3000
  {
2708
- 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').",
3001
+ description: "Read retained captured console output as a compact tail of timestamps, levels and messages. Filter by minimum level or a case-insensitive message substring. Fatal JavaScript errors may be included when supported hooks captured them; post-crash availability is not guaranteed. Missing entries do not establish that no error occurred. Use get_snapshot for available full entry details. Requires configured console capture and Buoy Pro.",
2709
3002
  inputSchema: {
2710
3003
  level: import_zod12.z.enum(["verbose", "info", "warning", "error"]).optional().describe("Minimum level to include (severity threshold). e.g. 'warning' = warnings + errors. Default all."),
2711
3004
  limit: import_zod12.z.number().optional().describe("Most-recent N entries. Default 50."),
@@ -2738,7 +3031,7 @@ function registerConsoleTools(server, sync) {
2738
3031
  ${lines.join("\n")}
2739
3032
  \`\`\`
2740
3033
 
2741
- _Tail of recent logs. Use get_snapshot('console') for full args/stacks._`);
3034
+ _Tail of recent logs. Use get_snapshot for available console entry details._`);
2742
3035
  })
2743
3036
  );
2744
3037
  }
@@ -2798,7 +3091,7 @@ function registerSentryTools(server, sync) {
2798
3091
  server.registerTool(
2799
3092
  "get_sentry_envelopes",
2800
3093
  {
2801
- description: "Read what the app is actually sending to Sentry \u2014 every outgoing envelope with its item types, byte size, span count, and summary. Use this to answer 'is this error reaching Sentry', 'what does my app send at startup', or 'why is my Sentry bill high'. Debugging an error locally? These payloads are the final post-processing event, so you can read the exception, breadcrumbs and contexts here WITHOUT waiting for ingestion or spending quota. Filter by envelope item type (event / transaction / log / session / client_report / attachment / replay_event).",
3094
+ description: "Inspect retained captured Sentry envelopes and available item types, sizes, spans and payload details. Coverage depends on supported hooks. Local capture does not prove successful delivery, ingestion or billing impact. Filter by item type to investigate the available evidence.",
2802
3095
  inputSchema: {
2803
3096
  type: import_zod13.z.string().optional().describe(
2804
3097
  "Only envelopes containing this item type, e.g. 'event' for errors or 'transaction' for traces."
@@ -2848,7 +3141,7 @@ _Full payloads (exception, breadcrumbs, contexts) via get_snapshot('sentry'). Bi
2848
3141
  server.registerTool(
2849
3142
  "get_sentry_cost",
2850
3143
  {
2851
- description: "What this session would cost in Sentry's billing units, and what that rate implies for a month against the free plan. Sentry bills on RECEIPT \u2014 data its server-side sampling later discards is still charged \u2014 and spans are billed individually, so one transaction is many units. Use this before shipping a Sentry config, or when asked why a Sentry bill is high.",
3144
+ description: "Inspect captured session usage and the tool's configured monthly extrapolation and quota assumptions. These estimates do not establish Sentry ingestion, current plan limits or an actual bill. Check the account's current terms and usage before making cost decisions.",
2852
3145
  inputSchema: {
2853
3146
  deviceId: import_zod13.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2854
3147
  }
@@ -2864,7 +3157,7 @@ _Full payloads (exception, breadcrumbs, contexts) via get_snapshot('sentry'). Bi
2864
3157
  const usage = p.usage;
2865
3158
  if (!usage?.session) {
2866
3159
  return text2(
2867
- `${staleHeader2}No usage recorded yet \u2014 nothing has been sent to Sentry this session.${statusNote(p.status)}`
3160
+ `${staleHeader2}No usage is available in this capture.${statusNote(p.status)}`
2868
3161
  );
2869
3162
  }
2870
3163
  const session = usage.session;
@@ -2877,7 +3170,7 @@ _Full payloads (exception, breadcrumbs, contexts) via get_snapshot('sentry'). Bi
2877
3170
  const month = projected[key];
2878
3171
  const quota = FREE_QUOTA[key];
2879
3172
  const pct = quota > 0 ? Math.round(month / quota * 100) : null;
2880
- const pctNote = pct === null ? " (not on the free plan)" : ` \u2014 ${pct}% of the free plan's ${units(quota, format)}`;
3173
+ const pctNote = pct === null ? " (no configured reference quota)" : ` \u2014 ${pct}% of the configured reference quota of ${units(quota, format)}`;
2881
3174
  return `- ${key}: ${now} this session \u2192 ~${units(month, format)}/mo${pctNote}`;
2882
3175
  });
2883
3176
  const windowNote = usage.windowMs ? ` over ${Math.round(usage.windowMs / 1e3)}s` : "";
@@ -2887,7 +3180,7 @@ _Full payloads (exception, breadcrumbs, contexts) via get_snapshot('sentry'). Bi
2887
3180
  ${usage.topContributors.map((c) => `- ${c.label} \u2014 ${c.units} ${c.category}`).join("\n")}` : "";
2888
3181
  const caveat = projected ? "\n\n_The monthly figure extrapolates one session on one device \u2014 it catches a config that is orders of magnitude off, it is not a forecast._" : "\n\n_Session too short to project a month from._";
2889
3182
  return text2(
2890
- `${staleHeader2}**Sentry cost \u2014 ${p.envelopes?.length ?? 0} envelopes${windowNote}:**
3183
+ `${staleHeader2}**Sentry usage estimate \u2014 ${p.envelopes?.length ?? 0} envelopes${windowNote}:**
2891
3184
 
2892
3185
  ${rows.join("\n")}${contributors}${caveat}`
2893
3186
  );
@@ -2917,9 +3210,9 @@ ${rows.join("\n")}${contributors}${caveat}`
2917
3210
  const recent = p.drops?.recent ?? [];
2918
3211
  if (summary.length === 0) {
2919
3212
  return text2(
2920
- `${staleHeader2}Nothing dropped this session \u2014 every captured event reached the transport.${statusNote(p.status)}
3213
+ `${staleHeader2}No drops are reported in the available capture.${statusNote(p.status)}
2921
3214
 
2922
- _If an event is still missing from sentry.io, it left the device: check the DSN, the project, and Sentry's own inbound filters and quota._`
3215
+ _If an event is missing from sentry.io, check capture coverage, transport errors, the DSN/project, inbound filters and quota. Local capture does not confirm delivery._`
2923
3216
  );
2924
3217
  }
2925
3218
  const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 30;
@@ -2998,7 +3291,7 @@ function registerTimeMachineTools(server, sync) {
2998
3291
  server.registerTool(
2999
3292
  "get_time_machine",
3000
3293
  {
3001
- 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.",
3294
+ 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 supported sources to repeat the flow. Uncaptured and backend state can still differ.",
3002
3295
  inputSchema: {
3003
3296
  deviceId: deviceIdInput
3004
3297
  }
@@ -3021,7 +3314,7 @@ function registerTimeMachineTools(server, sync) {
3021
3314
  }
3022
3315
  const snapLines = snapshots.map((s) => {
3023
3316
  const warnings = Object.values(s.sources).reduce((n, src) => n + src.warnings.length, 0);
3024
- const desc = s.baseline ? "baseline \u2014 restore wipes to fresh install (always reloads)" : `${kb(s.sizeBytes)} \xB7 ${Object.keys(s.sources).join(", ")}`;
3317
+ const desc = s.baseline ? "baseline: clear supported providers and request reload" : `${kb(s.sizeBytes)} \xB7 ${Object.keys(s.sources).join(", ")}`;
3025
3318
  const route = s.route ? ` \xB7 route ${s.route.href}` : "";
3026
3319
  return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${route}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
3027
3320
  });
@@ -3042,7 +3335,7 @@ ${formatOutcome(p.lastOutcome)}`);
3042
3335
  server.registerTool(
3043
3336
  "time_machine_action",
3044
3337
  {
3045
- 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.",
3338
+ description: "Manage snapshots of supported registered state sources: capture, preview, restore, capture_baseline, delete or rename. Preview affected items before restoring. Live mode replaces supported state in place; reload mode restores supported persisted storage and requests a JS reload. Baselines clear providers that support clearing and request a reload; they do not reset backend state or reproduce a complete reinstall. restoreRoute:true requests the captured route when available. Inspect per-source applied/skipped results and reload outcomes.",
3046
3339
  inputSchema: {
3047
3340
  action: import_zod14.z.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
3048
3341
  id: import_zod14.z.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
@@ -3189,7 +3482,7 @@ function registerScenariosTools(server, sync) {
3189
3482
  server.registerTool(
3190
3483
  "get_scenarios",
3191
3484
  {
3192
- description: "List the app's saved Scenarios \u2014 named, parameterized app states that can be applied with one call (override a response, write storage, impersonate a user, navigate). Shows what each one WILL do in plain English with a durability label per step, plus which scenario is currently active and what it changed. Use scenario_action to run one as a deterministic fixture BEFORE driving a flow, so the flow starts from a known state instead of whatever the app happened to be in. If a scenario is active, whatever you observe on the device is partly simulated \u2014 check here first when the app's data looks wrong.",
3485
+ description: "List saved scenarios, configured steps, available durability labels and the reported active scenario. Inspect these settings when app behavior may be simulated. A scenario covers its supported steps, not every source of app or backend state.",
3193
3486
  inputSchema: {
3194
3487
  deviceId: import_zod15.z.string().optional().describe("Target device id (auto-selected if one is connected).")
3195
3488
  }
@@ -3230,7 +3523,7 @@ ${drafts.map(summarize).join("\n")}`
3230
3523
  server.registerTool(
3231
3524
  "scenario_action",
3232
3525
  {
3233
- description: `Drive Buoy Scenarios: 'preview' dry-runs one and reports exactly what it WOULD change (plus any conflict with the active scenario) without touching the app \u2014 do this before running an unfamiliar scenario; 'run' applies it and resolves only once every step has landed, which makes it a deterministic fixture to call before run_flow; 'deactivate' reverses it and reports what it could NOT restore (storage writes have no automatic inverse); 'save' writes a new scenario onto the device \u2014 it lands as a DRAFT that a human must accept on the device before it can run, so tell the user to go accept it; 'setFolder' files a saved scenario under a flow ("Checkout", "Login") or clears its folder, which is how a QA library stays navigable \u2014 check get_scenarios for the folders already in use and reuse one instead of inventing a near-duplicate; 'delete' removes a device scenario. Prefer a network override step over a query-cache poke: the override survives refetch and restart, the poke dies on the next fetch.`,
3526
+ description: "Preview, run, deactivate, save, organize or delete a scenario. Preview unfamiliar scenarios before running; inspect applied steps, failures and unrestored effects afterward. Storage changes need suitable undo steps. Saved scenarios are drafts requiring acceptance on the device before running. Reuse existing folders when appropriate. A completed scenario does not guarantee a deterministic app or backend state; overrides and cache edits have different coverage and lifetimes.",
3234
3527
  inputSchema: {
3235
3528
  action: import_zod15.z.enum(["preview", "run", "deactivate", "save", "setFolder", "delete"]).describe("What to do."),
3236
3529
  folder: import_zod15.z.string().optional().describe(
@@ -3320,7 +3613,7 @@ ${r.willDo.map((s) => ` \xB7 ${s.label} [${s.durability}]`).join("\n")}`
3320
3613
  if (r.warnings?.length) lines.push(`**Warnings:**
3321
3614
  ${r.warnings.map((w) => ` \xB7 ${w}`).join("\n")}`);
3322
3615
  lines.push(
3323
- r.ok ? "\u2705 Pre-flight passed \u2014 safe to run." : `\u274C **Cannot run:**
3616
+ r.ok ? "Pre-flight checks passed. Review the intended effects before running." : `\u274C **Cannot run:**
3324
3617
  ${r.errors.map((e) => ` \xB7 ${e}`).join("\n")}`
3325
3618
  );
3326
3619
  return text3(lines.join("\n\n"));
@@ -3355,9 +3648,9 @@ The device is now in a partial state. Deactivate to reverse what landed, or fix
3355
3648
  [
3356
3649
  `\u2705 **Scenario active \u2014 ${r.name ?? args.id}**`,
3357
3650
  steps,
3358
- `**Now bent:**
3651
+ `**Reported effects:**
3359
3652
  ${(r.effects ?? []).map((e) => ` \xB7 ${e.label} [${e.kind}]`).join("\n")}`,
3360
- "The device shows a SIMULATED banner while this is active. Deactivate when done, or anything you observe from here is partly fake."
3653
+ "The device shows a SIMULATED banner while this is active. Deactivate when done, and account for these effects when interpreting observations."
3361
3654
  ].join("\n\n")
3362
3655
  );
3363
3656
  }
@@ -3422,10 +3715,10 @@ var import_zod16 = require("zod");
3422
3715
  // src/capture/simctl.ts
3423
3716
  var import_node_child_process = require("child_process");
3424
3717
  var import_node_util = require("util");
3425
- var import_node_fs2 = require("fs");
3426
- var import_node_os2 = __toESM(require("os"));
3427
- var import_node_path2 = __toESM(require("path"));
3428
- var import_node_crypto2 = require("crypto");
3718
+ var import_node_fs3 = require("fs");
3719
+ var import_node_os3 = __toESM(require("os"));
3720
+ var import_node_path3 = __toESM(require("path"));
3721
+ var import_node_crypto3 = require("crypto");
3429
3722
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
3430
3723
  var clamp2 = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
3431
3724
  function humanizeSimctlError(err) {
@@ -3451,7 +3744,7 @@ async function captureIosFramebuffer() {
3451
3744
  error: "Screenshotting a component requires macOS with Xcode's iOS Simulator (uses `xcrun simctl`)."
3452
3745
  };
3453
3746
  }
3454
- const out = import_node_path2.default.join(import_node_os2.default.tmpdir(), `buoy-mcp-sim-${(0, import_node_crypto2.randomUUID)()}.png`);
3747
+ const out = import_node_path3.default.join(import_node_os3.default.tmpdir(), `buoy-mcp-sim-${(0, import_node_crypto3.randomUUID)()}.png`);
3455
3748
  try {
3456
3749
  await execFileAsync("xcrun", ["simctl", "io", "booted", "screenshot", "--type=png", out], {
3457
3750
  timeout: 15e3
@@ -3460,7 +3753,7 @@ async function captureIosFramebuffer() {
3460
3753
  return { ok: false, error: humanizeSimctlError(err) };
3461
3754
  }
3462
3755
  try {
3463
- const buf = await import_node_fs2.promises.readFile(out);
3756
+ const buf = await import_node_fs3.promises.readFile(out);
3464
3757
  const size = pngSize(buf);
3465
3758
  if (!size) return { ok: false, error: "Captured file wasn't a valid PNG." };
3466
3759
  return { ok: true, srcPath: out, width: size.width, height: size.height };
@@ -3473,22 +3766,22 @@ async function cropToBuffer(srcPath, full, region) {
3473
3766
  const y = clamp2(Math.round(region.y), 0, Math.max(0, full.height - 1));
3474
3767
  const width = clamp2(Math.round(region.width), 1, full.width - x);
3475
3768
  const height = clamp2(Math.round(region.height), 1, full.height - y);
3476
- const out = import_node_path2.default.join(import_node_os2.default.tmpdir(), `buoy-mcp-crop-${(0, import_node_crypto2.randomUUID)()}.png`);
3769
+ const out = import_node_path3.default.join(import_node_os3.default.tmpdir(), `buoy-mcp-crop-${(0, import_node_crypto3.randomUUID)()}.png`);
3477
3770
  try {
3478
3771
  await execFileAsync(
3479
3772
  "sips",
3480
3773
  ["-c", String(height), String(width), "--cropOffset", String(y), String(x), srcPath, "--out", out],
3481
3774
  { timeout: 1e4 }
3482
3775
  );
3483
- const buf = await import_node_fs2.promises.readFile(out);
3776
+ const buf = await import_node_fs3.promises.readFile(out);
3484
3777
  return { ok: true, buffer: buf, width, height };
3485
3778
  } catch (err) {
3486
3779
  const e = err;
3487
3780
  return { ok: false, error: `Crop failed: ${e?.stderr || e?.message || err}` };
3488
3781
  } finally {
3489
- await import_node_fs2.promises.unlink(out).catch(() => {
3782
+ await import_node_fs3.promises.unlink(out).catch(() => {
3490
3783
  });
3491
- await import_node_fs2.promises.unlink(srcPath).catch(() => {
3784
+ await import_node_fs3.promises.unlink(srcPath).catch(() => {
3492
3785
  });
3493
3786
  }
3494
3787
  }
@@ -3560,11 +3853,11 @@ function registerScreenshotTools(server, sync) {
3560
3853
  server.registerTool(
3561
3854
  "screenshot_component",
3562
3855
  {
3563
- description: "Take a tight, cropped screenshot of a single React Native component running in the iOS Simulator \u2014 no manual coordinates needed. Give a testID, nativeID, accessibilityLabel, or component name in `query` (or an exact `nativeTag` from a previous call's candidates) and it locates the live component on-device (scrolling it into view), then captures + crops the Simulator framebuffer to just that component. If highlight tracking hasn't seen a render yet, it falls back to a live fiber walk (same as describe_screen). iOS Simulator only, and the MCP server must run on the same Mac as the booted Simulator. Requires Buoy Pro.",
3856
+ description: "Locate a supported React Native component and attempt a cropped iOS Simulator screenshot. Select by query or an exact nativeTag. Uses measured bounds or an available fiber-walk fallback and can attempt scrolling. Inspect the match and crop before treating it as visual evidence. Requires Buoy Pro, a compatible highlight-updates integration, and the MCP server on the same Mac as the booted iOS Simulator.",
3564
3857
  inputSchema: {
3565
3858
  query: import_zod16.z.string().optional().describe('Fuzzy match against testID, nativeID, accessibilityLabel, component name, or view type \u2014 e.g. "submit-button".'),
3566
3859
  nativeTag: import_zod16.z.number().optional().describe("An exact nativeTag (from a previous call's candidates) to skip fuzzy matching."),
3567
- scrollIntoView: import_zod16.z.boolean().optional().describe("Scroll the component into view before capturing, if it's off-screen. Default true."),
3860
+ scrollIntoView: import_zod16.z.boolean().optional().describe("Attempt to scroll an off-screen component into view before capture. Default true; inspect the result."),
3568
3861
  deviceId: deviceIdInput
3569
3862
  }
3570
3863
  },
@@ -3652,21 +3945,21 @@ var import_zod17 = require("zod");
3652
3945
  // src/capture/camera.ts
3653
3946
  var import_node_child_process2 = require("child_process");
3654
3947
  var import_node_util2 = require("util");
3655
- var import_node_fs3 = require("fs");
3656
- var import_node_os3 = __toESM(require("os"));
3657
- var import_node_path3 = __toESM(require("path"));
3948
+ var import_node_fs4 = require("fs");
3949
+ var import_node_os4 = __toESM(require("os"));
3950
+ var import_node_path4 = __toESM(require("path"));
3658
3951
  var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
3659
3952
  function candidates() {
3660
3953
  const fromEnv = process.env.BUOY_SIMCAM_DIR;
3661
3954
  const list = [
3662
- ...fromEnv ? [import_node_path3.default.join(fromEnv, "buoycam")] : [],
3955
+ ...fromEnv ? [import_node_path4.default.join(fromEnv, "buoycam")] : [],
3663
3956
  "/Applications/Buoy.app/Contents/Resources/simcam/buoycam",
3664
- import_node_path3.default.join(import_node_os3.default.homedir(), "Applications/Buoy.app/Contents/Resources/simcam/buoycam")
3957
+ import_node_path4.default.join(import_node_os4.default.homedir(), "Applications/Buoy.app/Contents/Resources/simcam/buoycam")
3665
3958
  ];
3666
3959
  let dir = __dirname;
3667
3960
  for (let i = 0; i < 8; i++) {
3668
- list.push(import_node_path3.default.join(dir, "apps/desktop/resources/simcam/buoycam"));
3669
- const parent = import_node_path3.default.dirname(dir);
3961
+ list.push(import_node_path4.default.join(dir, "apps/desktop/resources/simcam/buoycam"));
3962
+ const parent = import_node_path4.default.dirname(dir);
3670
3963
  if (parent === dir) break;
3671
3964
  dir = parent;
3672
3965
  }
@@ -3675,7 +3968,7 @@ function candidates() {
3675
3968
  var resolved = null;
3676
3969
  function cliPath() {
3677
3970
  if (resolved) return resolved;
3678
- resolved = candidates().find((c) => (0, import_node_fs3.existsSync)(c)) ?? null;
3971
+ resolved = candidates().find((c) => (0, import_node_fs4.existsSync)(c)) ?? null;
3679
3972
  return resolved;
3680
3973
  }
3681
3974
  async function run(args) {
@@ -3776,28 +4069,28 @@ function entitlementPaths() {
3776
4069
  const fromEnv = process.env.BUOY_ENTITLEMENT_FILE;
3777
4070
  if (fromEnv) return [fromEnv];
3778
4071
  return [
3779
- import_node_path3.default.join(import_node_os3.default.homedir(), "Library/Application Support/Buoy/entitlement.json"),
3780
- import_node_path3.default.join(import_node_os3.default.homedir(), "Library/Application Support/buoy-desktop/entitlement.json")
4072
+ import_node_path4.default.join(import_node_os4.default.homedir(), "Library/Application Support/Buoy/entitlement.json"),
4073
+ import_node_path4.default.join(import_node_os4.default.homedir(), "Library/Application Support/buoy-desktop/entitlement.json")
3781
4074
  ];
3782
4075
  }
3783
4076
  function readEntitlement() {
3784
4077
  for (const p of entitlementPaths()) {
3785
4078
  try {
3786
- if (!(0, import_node_fs3.existsSync)(p)) continue;
3787
- const parsed = JSON.parse((0, import_node_fs3.readFileSync)(p, "utf8"));
4079
+ if (!(0, import_node_fs4.existsSync)(p)) continue;
4080
+ const parsed = JSON.parse((0, import_node_fs4.readFileSync)(p, "utf8"));
3788
4081
  return {
3789
- tier: parsed.tier === "pro" || parsed.tier === "free" ? parsed.tier : "anonymous",
3790
- isPro: parsed.isPro === true
4082
+ tier: parsed.tier === "pro" || parsed.tier === "free" ? parsed.tier : "locked",
4083
+ isPro: parsed.isPro === true && parsed.hasDesktopAccess === true && typeof parsed.validUntil === "number" && Number.isFinite(parsed.validUntil) && Date.now() < parsed.validUntil && typeof parsed.proValidUntil === "number" && Number.isFinite(parsed.proValidUntil) && Date.now() < parsed.proValidUntil
3791
4084
  };
3792
4085
  } catch {
3793
4086
  }
3794
4087
  }
3795
- return { tier: "anonymous", isPro: false };
4088
+ return { tier: "locked", isPro: false };
3796
4089
  }
3797
4090
  function proRequired(tool) {
3798
4091
  return `\`${tool}\` is a Buoy Pro feature.
3799
4092
 
3800
- The simulator camera itself is free \u2014 the Mac camera, image, video, test-pattern and QR sources all work in the Buoy desktop panel without a licence. Pro adds the screen region, the non-QR symbologies (PDF417, Aztec, DataMatrix, Code 128), and driving any of it from an agent.
4093
+ The simulator camera itself is free \u2014 the Mac camera, image, video, test-pattern and QR sources all work in the Buoy desktop panel with a Free account. Pro adds the screen region, the non-QR symbologies (PDF417, Aztec, DataMatrix, Code 128), and driving any of it from an agent.
3801
4094
 
3802
4095
  _Tell the user to enter a licence in Buoy Desktop, or see https://buoy.gg/pricing. Don't try to work around it \u2014 use the desktop panel by hand instead._`;
3803
4096
  }
@@ -4044,7 +4337,7 @@ function registerInteractTools(server, sync) {
4044
4337
  server.registerTool(
4045
4338
  "describe_screen",
4046
4339
  {
4047
- 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.",
4340
+ description: "List elements exposed by the app React-fiber inspection integration, with available names, text, roles, testIDs, positions and native tags. This is not a complete native accessibility tree or a visual screenshot. Use the returned controls to select a target for tap_element; refresh after navigation or state changes. Empty results can reflect unavailable inspection. Requires Buoy Pro and the highlight-updates interaction integration.",
4048
4341
  inputSchema: {
4049
4342
  interactiveOnly: import_zod18.z.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
4050
4343
  includeBuoy: import_zod18.z.boolean().optional().describe(
@@ -4082,7 +4375,7 @@ ${elements.map(formatElement).join("\n")}`);
4082
4375
  server.registerTool(
4083
4376
  "tap_element",
4084
4377
  {
4085
- 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.",
4378
+ description: "Invoke a supported element handler in JavaScript: onPress/onLongPress, onValueChange or onChangeText. This does not reproduce native hit testing, keyboard entry or a physical gesture. Select an exposed element by nativeTag, testID or fuzzy query; inspect the matched target and resulting behavior. value sets a supported toggle or slider, text invokes a text-input handler, and longPress selects onLongPress. Can attempt to scroll an off-screen target into view. Use describe_screen for current controls and run_flow for sequences. Requires Buoy Pro and the app interaction integration.",
4086
4379
  inputSchema: {
4087
4380
  nativeTag: import_zod18.z.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
4088
4381
  testID: import_zod18.z.string().optional().describe("Exact testID of the element."),
@@ -4090,7 +4383,7 @@ ${elements.map(formatElement).join("\n")}`);
4090
4383
  value: import_zod18.z.union([import_zod18.z.number(), import_zod18.z.boolean()]).optional().describe("For a toggle: true/false (omit to flip). For a slider: the numeric value (omit to jump to the far end)."),
4091
4384
  text: import_zod18.z.string().optional().describe("For a text input: the string to set via onChangeText."),
4092
4385
  longPress: import_zod18.z.boolean().optional().describe("Invoke the element's onLongPress instead of onPress. Fails with a clear reason if the element has no onLongPress handler."),
4093
- scrollIntoView: import_zod18.z.boolean().optional().describe("Scroll the target into view first if it's off-screen. Default true."),
4386
+ scrollIntoView: import_zod18.z.boolean().optional().describe("Attempt to scroll an off-screen target into view. Default true; inspect the action result."),
4094
4387
  includeBuoy: import_zod18.z.boolean().optional().describe("Let a fuzzy `query` match Buoy's own overlay. Default false \u2014 an exact nativeTag/testID always may."),
4095
4388
  deviceId: deviceIdInput
4096
4389
  }
@@ -4214,11 +4507,11 @@ function registerJsThreadTools(server, sync) {
4214
4507
  server.registerTool(
4215
4508
  "get_js_thread_top",
4216
4509
  {
4217
- 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.',
4510
+ description: "Sample instrumented JavaScript task origins and return available timing, busy percentage and freeze attribution. Coverage depends on installed hooks; unattributed work can remain. Requires @buoy-gg/js-top and Buoy Pro. The call waits for the sample: arrange the interaction during that window using an available concurrent driver or a person, rather than starting it after this call returns. clearFirst resets tracked statistics before sampling. Use get_js_thread_origin_detail for a returned row key and measure_renders for component render diagnostics.",
4218
4511
  inputSchema: {
4219
4512
  deviceId: deviceIdInput,
4220
- durationMs: import_zod19.z.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
4221
- clearFirst: import_zod19.z.boolean().optional().describe("Reset all stats before sampling, so the result attributes only what happens during this sample.")
4513
+ durationMs: import_zod19.z.number().optional().describe("How long to sample before reading the table. Default 3000; clamped to 250\u201330000."),
4514
+ clearFirst: import_zod19.z.boolean().optional().describe("Reset tracked statistics before sampling. Hook coverage and attribution limits still apply.")
4222
4515
  }
4223
4516
  },
4224
4517
  async ({ deviceId, durationMs, clearFirst }) => {
@@ -4402,7 +4695,7 @@ function registerImagesTools(server, sync) {
4402
4695
  server.registerTool(
4403
4696
  "get_images",
4404
4697
  {
4405
- description: "Live image registry from the app: every RN <Image> and expo-image load with cache verdict (memory/disk/network), load ms, decoded vs displayed size with an oversize/upscale verdict and wasted-KB estimate, error details, and any active simulation overrides. Image HTTP never hits the JS network stack, so this is the only visibility into image loading. Requires @buoy-gg/images on the device.",
4698
+ description: "Inspect image loads reported through the Buoy image integration, including available cache origin, load timing, size estimates, errors and simulation overrides. Coverage depends on instrumented components and platform support. Requires @buoy-gg/images on the device.",
4406
4699
  inputSchema: {
4407
4700
  deviceId: deviceIdInput,
4408
4701
  limit: import_zod20.z.number().optional().describe("Max records to return (newest first). Default 30, max 200."),
@@ -4459,7 +4752,7 @@ ${JSON.stringify(slim)}
4459
4752
  server.registerTool(
4460
4753
  "image_action",
4461
4754
  {
4462
- description: "Act on one image from get_images (by id): hard_reload (bypass caches + refetch), retry, evict_disk (expo disk entry), flash (red border to locate it on screen), prove_savings (re-encode at displayed size as WebP ON DEVICE and return real byte savings + a previewable file:// uri), or simulation overrides \u2014 force_error (instant offline-safe failure), force_loading (request hangs forever), force_blank, swap_url (requires url), restore (remove override). Returns the record's state after the action so the effect is visible.",
4755
+ description: "Request an action for a registered image ID: hard_reload, retry, evict_disk, flash, prove_savings or a supported simulation override. swap_url requires url; restore removes the override. Support depends on the component and platform. prove_savings attempts a WebP conversion at displayed size and reports available byte comparisons and a device-local URI; inspect visual quality and originalBytesSource before using the result. Other actions return an acknowledgement and, when available, a later record snapshot; that snapshot may precede load completion. A device-local URI is not necessarily accessible from the editor host.",
4463
4756
  inputSchema: {
4464
4757
  deviceId: deviceIdInput,
4465
4758
  id: import_zod20.z.number().describe("Record id from get_images."),
@@ -4529,12 +4822,12 @@ ${JSON.stringify(slim)}
4529
4822
  server.registerTool(
4530
4823
  "set_image_simulation",
4531
4824
  {
4532
- description: "App-wide image simulation modes: network 'offline' (every NETWORK image load fails immediately, both libs \u2014 bundled assets still load; reload/remount on-screen images to see it), 'cold' (every image bypasses caches \u2014 first-launch behavior, nothing cleared), 'normal' to reset. blankImages renders every image without a source. massAction applies per-image simulations to everything mounted. Also clears the registry or expo caches when asked.",
4825
+ description: "Configure supported instrumented image loads: offline simulates network-load failures while bundled assets can still load; cold requests cache bypass; normal resets the network mode. Reload or remount existing images as needed. blankImages removes sources from integrated images; massAction targets registered mounted images. Can also clear the registry or supported Expo caches. These modes do not change device connectivity.",
4533
4826
  inputSchema: {
4534
4827
  deviceId: deviceIdInput,
4535
4828
  network: import_zod20.z.enum(["normal", "offline", "cold"]).optional().describe("Network simulation mode."),
4536
- blankImages: import_zod20.z.boolean().optional().describe("Render all images without a source."),
4537
- massAction: import_zod20.z.enum(["error", "loading", "blank", "reload", "flash", "restore"]).optional().describe("Apply to EVERY mounted image at once: force error/loading/blank overrides, hard-reload all, flash all, or restore all overrides."),
4829
+ blankImages: import_zod20.z.boolean().optional().describe("Render integrated images without a source."),
4830
+ massAction: import_zod20.z.enum(["error", "loading", "blank", "reload", "flash", "restore"]).optional().describe("Apply to registered mounted images: force error/loading/blank overrides, hard-reload all, flash all, or restore all overrides."),
4538
4831
  clearRegistry: import_zod20.z.boolean().optional().describe("Clear the captured image log."),
4539
4832
  clearExpoCaches: import_zod20.z.boolean().optional().describe("Clear expo-image memory + disk caches.")
4540
4833
  }
@@ -4673,7 +4966,7 @@ function registerAssetsTools(server, sync) {
4673
4966
  server.registerTool(
4674
4967
  "get_assets",
4675
4968
  {
4676
- description: "Bundled-asset inventory from the app: every asset (images, fonts, video, audio) with dimensions, @1x/@2x/@3x scale coverage, byte sizes (measured from the Metro server in dev), loaded vs never-loaded state, and audit findings \u2014 duplicates (by content hash), shipped-but-never-loaded, WebP candidates, images decoding larger than the screen, heavy GIFs/fonts, scale gaps \u2014 plus the change vs a saved baseline. This is what the app SHIPS; use get_images for what it RENDERS. Requires @buoy-gg/assets on the device.",
4969
+ description: "Inspect assets reported by the configured manifest, runtime registry and available Metro bundle graph. Includes available dimensions, scale coverage, byte sizes, observed load history, audit findings and saved-baseline differences. Size measurement and coverage depend on setup; not observed loading does not mean unused. Use get_images for instrumented runtime image loads. Requires @buoy-gg/assets on the device.",
4677
4970
  inputSchema: {
4678
4971
  deviceId: deviceIdInput,
4679
4972
  limit: import_zod21.z.number().optional().describe("Max records to return (largest first). Default 40, max 500."),
@@ -4806,12 +5099,12 @@ function messageSocketUrl(origin) {
4806
5099
  return `${origin.replace(/^http/, "ws").replace(/\/+$/, "")}/message`;
4807
5100
  }
4808
5101
  function reloadViaDevServer(origin) {
4809
- return new Promise((resolve) => {
5102
+ return new Promise((resolve2) => {
4810
5103
  let settled = false;
4811
5104
  const done = (result) => {
4812
5105
  if (settled) return;
4813
5106
  settled = true;
4814
- resolve(result);
5107
+ resolve2(result);
4815
5108
  };
4816
5109
  let socket;
4817
5110
  try {
@@ -4896,7 +5189,7 @@ function registerReloadTools(server, sync) {
4896
5189
  server.registerTool(
4897
5190
  "reload_app",
4898
5191
  {
4899
- 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.",
5192
+ description: "Request a JS reload for a supported app using DevSettings in development or installed expo-updates where available. For an unresponsive app, can try its known dev server when reachable. Recovery is not guaranteed. Reload can discard in-memory state and evidence; collect needed observations first when possible. By default waits for reconnection up to timeoutMs. Requires configured @buoy-gg/core support and Buoy Pro.",
4900
5193
  inputSchema: {
4901
5194
  strategy: import_zod22.z.enum(["auto", "dev-settings", "expo-updates"]).optional().describe(
4902
5195
  "Reload mechanism. 'auto' (default) uses DevSettings in dev builds and expo-updates otherwise. Force one only when debugging the reload itself."
@@ -5020,7 +5313,7 @@ function registerTriageTools(server, sync) {
5020
5313
  server.registerTool(
5021
5314
  "get_triage",
5022
5315
  {
5023
- 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.",
5316
+ description: "Summarize available runtime observations for a connected app: captured console errors, recent network requests, configured storage and screen information. Use the returned sections and errors to choose follow-up tools. Missing data or an unresponsive app does not prove a crash; crash output may be unavailable. Requires Buoy Pro.",
5024
5317
  inputSchema: {
5025
5318
  deviceId: import_zod23.z.string().optional().describe("Target device id (auto-selected if one is connected).")
5026
5319
  }
@@ -5187,10 +5480,10 @@ function registerFlowTools(server, sync) {
5187
5480
  server.registerTool(
5188
5481
  "run_flow",
5189
5482
  {
5190
- 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.",
5483
+ description: "Run supported UI actions and element checks in sequence, with per-step results. Stops on the first failed step unless continueOnFailure is true. Depending on include, attempts to return the final element tree and newly observed captured network/console entries. Background activity can appear in that output; it does not establish causation. Actions use exposed app handlers, not physical gestures. Review failures and check the expected behavior. Requires Buoy Pro and the app interaction integration.",
5191
5484
  inputSchema: {
5192
5485
  steps: import_zod24.z.array(stepSchema).min(1).describe("Steps to run, in order."),
5193
- include: import_zod24.z.enum(["all", "screen", "none"]).optional().describe("What to report after the flow: 'all' (default) = final screen + network/console produced by the flow; 'screen' = final screen only; 'none' = step results only."),
5486
+ include: import_zod24.z.enum(["all", "screen", "none"]).optional().describe("What to report after the flow: 'all' (default) = available final element tree + newly observed captured network/console entries; 'screen' = available final element tree only; 'none' = step results only."),
5194
5487
  continueOnFailure: import_zod24.z.boolean().optional().describe("Keep going after a failed step instead of stopping. Default false."),
5195
5488
  deviceId: deviceIdInput
5196
5489
  }
@@ -5512,7 +5805,7 @@ function formatCapture(c, top) {
5512
5805
  }
5513
5806
  lines.push("");
5514
5807
  lines.push(
5515
- "`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."
5808
+ "`wasted` classifies parent cascades and identity-only prop changes. Investigate whether these renders are needed before changing them. Rows are sorted by **self ms**. Compare timing and render counts for the measured workload, and check behavior before deciding whether memoization helps."
5516
5809
  );
5517
5810
  return lines.join("\n");
5518
5811
  }
@@ -5548,14 +5841,14 @@ function registerRenderTools(server, sync) {
5548
5841
  server.registerTool(
5549
5842
  "measure_renders",
5550
5843
  {
5551
- 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.",
5844
+ description: "Measure captured component renders during supplied interaction steps or an idle window. Returns available timings, render counts, reported reasons and changed props. The wasted count is a diagnostic classification, not proof a render can be removed. Check interaction warnings before comparing runs. Use the same steps and comparable conditions with compareToPrevious:true after editing; the comparison alone does not prove correctness or overall performance. Requires Buoy Pro and a highlight-updates integration exposing beginMeasurement. Use run_benchmark_batch to compare route-based variants.",
5552
5845
  inputSchema: {
5553
5846
  steps: import_zod25.z.array(stepSchema2).max(20).optional().describe(
5554
5847
  "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."
5555
5848
  ),
5556
5849
  idleMs: import_zod25.z.number().min(500).max(2e4).optional().describe(`Idle sample length when no steps are given. Default ${DEFAULT_IDLE_MS}.`),
5557
5850
  label: import_zod25.z.string().optional().describe('Name this measurement, e.g. "before fix". Shown in the next comparison.'),
5558
- compareToPrevious: import_zod25.z.boolean().optional().describe("Diff against the previous measure_renders on this device. Use after an edit to prove the fix."),
5851
+ compareToPrevious: import_zod25.z.boolean().optional().describe("Compare with the previous measurement on this device. Use equivalent steps and conditions; review warnings and behavior separately."),
5559
5852
  top: import_zod25.z.number().min(1).max(40).optional().describe(`Components to list. Default ${DEFAULT_TOP}.`),
5560
5853
  deviceId: deviceIdInput
5561
5854
  }
@@ -5645,7 +5938,7 @@ function registerRenderTools(server, sync) {
5645
5938
  "_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._"
5646
5939
  );
5647
5940
  } else if (!args.compareToPrevious) {
5648
- parts.push("", "_Fixed something? Call this again with the same steps and `compareToPrevious:true` to prove it landed._");
5941
+ parts.push("", "_Compare another run using the same steps and `compareToPrevious:true`; check behavior and interaction warnings as well._");
5649
5942
  }
5650
5943
  return text8(parts.join("\n"));
5651
5944
  } catch (e) {
@@ -5656,8 +5949,8 @@ function registerRenderTools(server, sync) {
5656
5949
  }
5657
5950
 
5658
5951
  // src/verify/editWatcher.ts
5659
- var import_node_fs4 = __toESM(require("fs"));
5660
- var import_node_path4 = __toESM(require("path"));
5952
+ var import_node_fs5 = __toESM(require("fs"));
5953
+ var import_node_path5 = __toESM(require("path"));
5661
5954
  var WATCHED_EXT = /* @__PURE__ */ new Set([
5662
5955
  ".ts",
5663
5956
  ".tsx",
@@ -5700,14 +5993,14 @@ var EditWatcher = class {
5700
5993
  if (mode === "never" || this.started) return;
5701
5994
  this.started = true;
5702
5995
  try {
5703
- this.watcher = import_node_fs4.default.watch(
5996
+ this.watcher = import_node_fs5.default.watch(
5704
5997
  rootDir,
5705
5998
  { recursive: true },
5706
5999
  (_event, filename) => {
5707
6000
  if (!filename) return;
5708
6001
  const rel2 = String(filename);
5709
- if (rel2.split(import_node_path4.default.sep).some((seg) => IGNORED_DIRS.has(seg))) return;
5710
- if (!WATCHED_EXT.has(import_node_path4.default.extname(rel2))) return;
6002
+ if (rel2.split(import_node_path5.default.sep).some((seg) => IGNORED_DIRS.has(seg))) return;
6003
+ if (!WATCHED_EXT.has(import_node_path5.default.extname(rel2))) return;
5711
6004
  this.noteEdit(rel2);
5712
6005
  }
5713
6006
  );
@@ -5758,129 +6051,89 @@ var EditWatcher = class {
5758
6051
 
5759
6052
  ---
5760
6053
  \u26A0\uFE0F **${files.length} source file${files.length === 1 ? "" : "s"} changed since anything was last checked on the device** (${shown}${more}).
5761
- 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.`;
6054
+ For a behavior change, repeat the affected interaction and check the expected result. Use \`measure_renders\` with the same steps and \`compareToPrevious: true\` for render comparisons, or \`run_flow\` for supported behavior checks. Report what was checked and any remaining limits. This reminder tracks edits and tool calls; clearing it does not prove the change is correct.`;
5762
6055
  }
5763
6056
  };
5764
6057
  var editWatcher = new EditWatcher();
5765
6058
 
5766
6059
  // src/server.ts
5767
- var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
5768
-
5769
- Buoy tools live inside the app and sync to a local broker; this server connects to that
5770
- broker as a dashboard client.
5771
-
5772
- DEBUGGING A RUNNING APP? Call \`get_triage\` FIRST \u2014 one call returns the connected device,
5773
- recent console errors (including [FATAL] crashes), recent network requests with statuses,
5774
- persisted storage keys, and what's on screen. It replaces the usual five-call opening
5775
- sequence and tells you which tool to drill into. Read runtime facts before hypothesizing
5776
- from source: the app's actual state beats a guess about what the code should do.
5777
-
5778
- Routing after triage \u2014 match the SYMPTOM to the tool:
5779
- - Value wrong/stale/NaN, or broken only after an upgrade \u2192 \`get_storage includeValues:true\`
5780
- (a bad persisted value is a top root cause, and it survives reinstalls of the JS).
5781
- - Failed request, wrong data from the API, auth/session bug \u2192 \`get_network_requests
5782
- includeBodies:true\` and read the response BODY.
5783
- - Crash or redbox \u2192 \`get_console\` (fatals are captured, and still readable after the app
5784
- dies), then \`get_storage\` for the state that triggered it.
5785
- - Data on screen doesn't match the API \u2192 \`get_react_query\` / \`get_zustand_state\` /
5786
- \`get_redux_state\` / \`get_jotai_state\` for the cache or store between them.
5787
-
5788
- AFTER YOU EDIT, PROVE IT on the device \u2014 the most common way a fix fails is being incomplete,
5789
- not being wrong. Reload (\`run_flow\` step \`{reload:true}\`, or \`reload_app\`) and re-drive EVERY
5790
- screen the symptom could appear on, asserting the corrected value: a price shown in three places
5791
- has to be right in all three, and a migration only counts if it survives a restart. A fix you
5792
- haven't watched work is a guess.
5793
-
5794
- Use \`list_devices\` when you need the full tool/action inventory or several devices are
5795
- connected (\`get_triage\` already names the active one).
5796
-
5797
- To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
5798
- react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
5799
- filter (e.g. sources:["network"] to list HTTP requests). It's the universal, token-bounded
5800
- activity reader across ALL tools and is compact by default \u2014 reach for it before reading source
5801
- code, and prefer it over \`get_snapshot\` for any "what happened / show me the requests" question.
5802
-
5803
- Working on ONE HTTP request rather than skimming activity? \`get_events\` deliberately emits no
5804
- request ids, so use \`get_network_requests\` \u2014 same compact style, but it leads with the id every
5805
- network action is keyed by, and marks which requests are pinned/saved. \`flagged:'any'\` lists the
5806
- requests the DEVELOPER kept: those are snapshots that outlive Clear, the 500-request cap and app
5807
- restarts, so treat a pinned request as "a human flagged this as the problem". \`network_action\`
5808
- pins/saves one \u2014 pin the failing call you want a human to see, or keep one before the log rolls over.
5809
-
5810
- Testing anything that opens a CAMERA \u2014 a QR/barcode scanner, document capture, an avatar
5811
- picker? The iOS Simulator has no camera, so those screens normally cannot be exercised at
5812
- all. \`camera_source\` gives it one: \`barcode\` generates the code from text you supply
5813
- (pdf417 is what a driver's licence carries), \`image\`/\`video\` play a file, \`screen\` points
5814
- the phone at this Mac. \`camera_devices\` lists the booted simulators and their apps when
5815
- you need a bundle id; \`camera_inputs\` lists this Mac's cameras and windows when you need
5816
- one of those ids. Then \`camera_launch\` to relaunch the app with it attached \u2014 a camera
5817
- can only be attached at process start \u2014 or \`camera_zero_setup\` when something else starts
5818
- the app. \`camera_status\` shows what is being decoded right now, which separates "the
5819
- scanner is broken" from "it never saw the code", \`camera_diagnose\` says why when nothing
5820
- works, and \`camera_stop\` turns it off when you are done. These need no device connection
5821
- and no Buoy integration in the app.
5822
-
5823
- Need to SEE a component (verify a UI change, check styling/layout)? Use \`screenshot_component\` with
5824
- a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
5825
- iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
5826
-
5827
- Need to DRIVE the UI? Reach for \`run_flow\` FIRST: it runs a whole scripted sequence
5828
- (tap / type / set / reload / waitFor / expect) in ONE call and reports the per-step result, the
5829
- final screen, and the network + console output the flow produced. Reproducing a bug and verifying
5830
- a fix are one call each instead of a dozen alternating reads and taps, and a failed step reports
5831
- the screen at the point it broke. Drop to \`describe_screen\` (list on-screen elements with testIDs
5832
- and tap points) + \`tap_element\` only for one-off pokes or when you don't yet know what's there.
5833
-
5834
- Screen slow / laggy / janky / stutters, typing lags, or "the whole page re-renders when I tap
5835
- one thing"? Start with \`measure_renders\` \u2014 one call, no test route needed. Pass \`steps\` for the
5836
- interaction you're actually asking about (a tap, some typing); an idle sample says nothing about
5837
- what a tap costs. It returns per-component render counts, WHY each rendered (parent cascade / own
5838
- state / changed props), which parent dragged it along, and \`wasted\` \u2014 the renders that produced
5839
- nothing new. After you edit, call it again with the same steps and \`compareToPrevious:true\`:
5840
- re-render fixes routinely move the cost somewhere else instead of removing it, and only a second
5841
- measurement catches that.
5842
-
5843
- JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
5844
- \`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
5845
- freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
5846
- Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
5847
- This is about TASKS on the thread \u2014 for React components re-rendering too often, it's
5848
- \`measure_renders\`, not this.
5849
-
5850
- Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
5851
- expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
5852
- simulates failures per image; \`set_image_simulation\` flips app-wide offline/cold/blank modes.
5853
-
5854
- Auditing app size / bundled assets? \`get_assets\` inventories everything the app SHIPS \u2014 images,
5855
- fonts, video, audio \u2014 with byte sizes, scale coverage, and findings (duplicates, never-loaded,
5856
- WebP candidates, heavy GIFs/fonts). \`asset_action\` rescans, fetches per-asset detail, and runs
5857
- the baseline diff workflow (save_baseline \u2192 change something \u2192 get_assets shows what it added).
5858
- get_images = what RENDERS at runtime; get_assets = what SHIPS in the bundle.
5859
-
5860
- Need the app RESTARTED (a change Fast Refresh didn't pick up, leaked in-memory state before a
5861
- measurement, re-running startup)? \`reload_app\` reloads the JS bundle \u2014 DevSettings.reload() in dev
5862
- builds, expo-updates otherwise \u2014 and waits for the app to reconnect. It wipes in-memory state, so
5863
- re-read anything you cached.
5864
-
5865
- Testing a multi-step flow repeatedly (cart \u2192 checkout, login states, a bug that needs specific
5866
- state)? \`time_machine_action\` captures the app's COMPLETE client state (storage, redux, zustand,
5867
- jotai, react-query cache) as a named restore point and restores it in one call \u2014 capture once,
5868
- drive the flow with \`tap_element\`, restore, repeat. \`get_time_machine\` lists restore points and
5869
- which sources are restorable. Restore mode 'live' swaps state in place; 'reload' restores
5870
- persisted storage then reloads the bundle (bulletproof; baselines = wipe to fresh install).
5871
- Check the per-source applied/skipped results it returns \u2014 a partial restore is reported, not hidden.
5872
-
5873
- For everything else:
5874
- - \`list_tool_actions\` to see what a tool can do,
5875
- - \`get_snapshot\` ONLY when you need a tool's full current state (returns raw, heavy payloads),
5876
- - \`call_action\` to invoke any tool action generically.
5877
-
5878
- Higher-level tools (e.g. perf-monitor's \`run_benchmark_batch\`) wrap these for common workflows.
5879
- If no device is connected, open the app with Buoy devtools running and try again.
5880
-
5881
- The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
5882
- require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
5883
- required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
6060
+ var SERVER_INSTRUCTIONS = `Buoy MCP connects your editor to supported Buoy tools through the configured broker.
6061
+
6062
+ Start with account and connection setup. Use \`list_devices\` to select an app and inspect
6063
+ its available tools. App data and action tools require Buoy Pro. If access fails, explain
6064
+ the reported requirement and setup steps. Camera tools use a separate local setup and
6065
+ account gate; \`camera_diagnose\` can report setup problems without an app connection.
6066
+
6067
+ For a runtime bug, \`get_triage\` summarizes available console errors, network activity,
6068
+ storage and screen information. Follow up on the relevant section and read source when
6069
+ needed. Missing data can reflect missing integration, retention limits or a disconnected
6070
+ app. An unresponsive app is not proof of a crash, and post-crash evidence may be unavailable.
6071
+
6072
+ Choose follow-up tools by the symptom:
6073
+ - Persisted values: \`get_storage\` with \`includeValues:true\` for configured storage sources.
6074
+ - Requests or API responses: \`get_network_requests\` with \`includeBodies:true\` for captured
6075
+ bodies. Capture depends on supported transports, installed hooks and recording timing.
6076
+ - Errors: \`get_console\` for retained captured output. A stopped app cannot provide new data.
6077
+ - Cache or store state: \`get_react_query\`, \`get_zustand_state\`, \`get_redux_state\` or
6078
+ \`get_jotai_state\` for the integrations registered in the app.
6079
+
6080
+ Use \`get_events\` with \`sources\` to read retained activity from connected event sources.
6081
+ It returns compact summaries by default; request \`includeData:true\` when details are needed.
6082
+ For request IDs and network actions, use \`get_network_requests\`. Its \`flagged:'any'\` filter
6083
+ finds pinned or saved requests. A flag is a reason to inspect a request, not proof of its
6084
+ cause or who flagged it. Use \`network_action\` to retain relevant evidence within task scope.
6085
+
6086
+ For supported UI interactions, \`run_flow\` runs steps such as tap, type, set, reload,
6087
+ waitFor and expect. Review step results, failures and available captured output. Use
6088
+ \`describe_screen\` to discover exposed elements and \`tap_element\` for individual actions.
6089
+ A completed tool call alone does not establish that the user's expected behavior occurred.
6090
+
6091
+ For render-heavy interactions, use \`measure_renders\` with representative \`steps\`.
6092
+ Repeat the same steps after a change with \`compareToPrevious:true\`. Interpret available
6093
+ render counts, reasons and wasted-render diagnostics alongside behavior and performance;
6094
+ these diagnostics do not prove that a render was unnecessary. For instrumented JavaScript
6095
+ tasks, use \`get_js_thread_top\` and \`get_js_thread_origin_detail\`. Their coverage depends
6096
+ on the installed hooks. Keep measurement conditions comparable.
6097
+
6098
+ \`get_images\` inspects instrumented image loads and available cache, timing, size and error
6099
+ data. Use \`image_action\` for supported per-image actions and \`set_image_simulation\` for
6100
+ integrated image behavior. Simulation does not disconnect the device's network.
6101
+ \`get_assets\` inspects the configured manifest, runtime registry and available bundle graph.
6102
+ Reported load history and size estimates do not prove complete shipped-asset coverage or
6103
+ that an unobserved asset is unused. \`asset_action\` can refresh data and manage baselines.
6104
+
6105
+ \`reload_app\` requests a JS reload using supported app mechanisms and can wait for
6106
+ reconnection. It can try a dev-server fallback for an unresponsive app when that server
6107
+ is known and reachable. Recovery is not guaranteed. Reload can discard in-memory evidence;
6108
+ collect needed observations first when possible and read state again after reconnecting.
6109
+
6110
+ \`get_time_machine\` lists snapshots and provider capabilities. \`time_machine_action\`
6111
+ captures or restores supported registered state sources. Preview the affected state before
6112
+ restoring. Live restore changes supported state in place; reload mode restores supported
6113
+ persisted sources and requests a reload. A baseline clears providers that support clearing
6114
+ and requests a reload. This does not reset backend state or reproduce a complete reinstall.
6115
+ Check per-source applied/skipped outcomes and any reload or route-restoration failures.
6116
+
6117
+ For iOS Simulator camera input, inspect setup with \`camera_diagnose\`. Use \`camera_source\`
6118
+ for supported barcode, file or screen input, \`camera_devices\` for simulators/apps and
6119
+ \`camera_inputs\` for local sources. \`camera_launch\` attaches input when launching the app;
6120
+ \`camera_zero_setup\` configures attachment for another launcher. \`camera_status\` reports
6121
+ available source/decoder status, which does not establish the app's scanner result.
6122
+ Use \`camera_stop\` when finished. These tools operate locally without an in-app Buoy
6123
+ integration, but require their supported host, simulator and account setup.
6124
+
6125
+ \`screenshot_component\` locates a supported component by testID/name and attempts a crop.
6126
+ It requires the MCP server on the same Mac as the booted iOS Simulator and the component
6127
+ inspection setup. Inspect the result before using it as visual evidence.
6128
+
6129
+ For other supported operations, use \`list_tool_actions\`, \`get_snapshot\` or \`call_action\`.
6130
+ Snapshots describe available current state and can be large; request only needed data.
6131
+
6132
+ After a behavior change, repeat the affected interaction and check the expected result.
6133
+ Include restart or persistence checks when relevant to the change. Use appropriate static
6134
+ checks for documentation-only work. Report actual checks, observations and remaining limits.
6135
+ BUOY_VERIFY reminders track selected edits and tool calls; clearing a reminder is not a
6136
+ test result. Stay within the user's authorized task and preserve existing project work.`;
5884
6137
  var VERIFYING_TOOLS = /* @__PURE__ */ new Set([
5885
6138
  "measure_renders",
5886
6139
  "scenario_action",
@@ -5953,9 +6206,9 @@ var deviceIdField = {
5953
6206
 
5954
6207
  // src/init.ts
5955
6208
  var import_node_child_process3 = require("child_process");
5956
- var import_node_fs5 = __toESM(require("fs"));
5957
- var import_node_os4 = __toESM(require("os"));
5958
- var import_node_path5 = __toESM(require("path"));
6209
+ var import_node_fs6 = __toESM(require("fs"));
6210
+ var import_node_os5 = __toESM(require("os"));
6211
+ var import_node_path6 = __toESM(require("path"));
5959
6212
  function defaultEnv() {
5960
6213
  return { BUOY_VERIFY: "auto" };
5961
6214
  }
@@ -5963,34 +6216,34 @@ var PUBLIC_REGISTRY = "https://registry.npmjs.org";
5963
6216
  var PROBE_TIMEOUT_MS3 = 3e3;
5964
6217
  function runInit(opts = {}) {
5965
6218
  const cwd = opts.cwd ?? process.cwd();
5966
- const skillSrcDir = opts.skillSrcDir ?? import_node_path5.default.resolve(__dirname, "../skill");
6219
+ const skillSrcDir = opts.skillSrcDir ?? import_node_path6.default.resolve(__dirname, "../skill");
5967
6220
  const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
5968
6221
  const entry = resolveEntry(opts, log);
5969
6222
  const done = [];
5970
- upsertMcpConfig(import_node_path5.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
5971
- import_node_fs5.default.mkdirSync(import_node_path5.default.join(cwd, ".cursor"), { recursive: true });
5972
- upsertMcpConfig(import_node_path5.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
5973
- if (import_node_fs5.default.existsSync(import_node_path5.default.join(cwd, ".vscode"))) {
5974
- upsertMcpConfig(import_node_path5.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
6223
+ upsertMcpConfig(import_node_path6.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
6224
+ import_node_fs6.default.mkdirSync(import_node_path6.default.join(cwd, ".cursor"), { recursive: true });
6225
+ upsertMcpConfig(import_node_path6.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
6226
+ if (import_node_fs6.default.existsSync(import_node_path6.default.join(cwd, ".vscode"))) {
6227
+ upsertMcpConfig(import_node_path6.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
5975
6228
  }
5976
6229
  installSkills(skillSrcDir, cwd, done);
5977
- appendDebuggingGuide(import_node_path5.default.join(cwd, "CLAUDE.md"), cwd, done);
5978
- appendDebuggingGuide(import_node_path5.default.join(cwd, "AGENTS.md"), cwd, done);
5979
- log("Buoy MCP installed:");
6230
+ appendDebuggingGuide(import_node_path6.default.join(cwd, "CLAUDE.md"), cwd, done);
6231
+ appendDebuggingGuide(import_node_path6.default.join(cwd, "AGENTS.md"), cwd, done);
6232
+ log("Buoy MCP configuration written:");
5980
6233
  for (const line of done) log(" \u2022 " + line);
5981
6234
  log("");
5982
6235
  log("Next steps:");
5983
6236
  log(" 1. Restart your editor (or reconnect the MCP server) to load `buoy`.");
5984
- log(" 2. Open your app with Buoy devtools running on a device or simulator.");
5985
- log(' 3. Say "buoy optimize" to start a guided performance pass.');
6237
+ log(" 2. Complete account setup, then open an app with the required Buoy tools and sync integration.");
6238
+ log(" 3. Confirm the device and tools are available. MCP data/action access requires Pro.");
5986
6239
  if (entry.command === "npx") {
5987
6240
  log("");
5988
- log(`This launches the server via \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
5989
- log(`up to date automatically. To force-refresh, re-run \`npx -y ${PACKAGE_NAME}@latest init\`.`);
6241
+ log(`The editor launches the server with \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
6242
+ log(`Registry access may be needed. Re-run \`npx -y ${PACKAGE_NAME}@latest init\` to refresh configuration.`);
5990
6243
  } else if (!opts.cliPath) {
5991
6244
  log("");
5992
6245
  log(`This uses a pinned local install (no network on startup). To update it later,`);
5993
- log(`re-run init (\`node ${entry.args[0]?.replace(/dist[/\\]cli\.js$/, "") ?? ""}...\` \u2014 or reinstall).`);
6246
+ log(`To update the local package, run: npx -y ${entry.args[0]?.replace(/dist[/\\]cli\.js$/, "") ?? ""}@latest init --local`);
5994
6247
  }
5995
6248
  }
5996
6249
  function resolveEntry(opts, log) {
@@ -6017,7 +6270,7 @@ function npxEntry() {
6017
6270
  return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`], env: defaultEnv() };
6018
6271
  }
6019
6272
  function localEntry(opts, log) {
6020
- const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path5.default.join(import_node_os4.default.homedir(), ".buoy-mcp");
6273
+ const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path6.default.join(import_node_os5.default.homedir(), ".buoy-mcp");
6021
6274
  const registry = opts.registry ?? PUBLIC_REGISTRY;
6022
6275
  const cliPath2 = installLocalCopy(installDir, registry, log);
6023
6276
  if (cliPath2) {
@@ -6028,7 +6281,7 @@ function localEntry(opts, log) {
6028
6281
  }
6029
6282
  function installLocalCopy(installDir, registry, log) {
6030
6283
  const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
6031
- import_node_fs5.default.mkdirSync(installDir, { recursive: true });
6284
+ import_node_fs6.default.mkdirSync(installDir, { recursive: true });
6032
6285
  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
6033
6286
  log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
6034
6287
  const res = (0, import_node_child_process3.spawnSync)(
@@ -6051,8 +6304,8 @@ function installLocalCopy(installDir, registry, log) {
6051
6304
  log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
6052
6305
  return null;
6053
6306
  }
6054
- const cliPath2 = import_node_path5.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
6055
- if (!import_node_fs5.default.existsSync(cliPath2)) {
6307
+ const cliPath2 = import_node_path6.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
6308
+ if (!import_node_fs6.default.existsSync(cliPath2)) {
6056
6309
  log(`\u26A0 install finished but ${cliPath2} is missing.`);
6057
6310
  return null;
6058
6311
  }
@@ -6104,129 +6357,109 @@ fetch(url, { method: 'HEAD', signal: c.signal })
6104
6357
  `;
6105
6358
  function upsertMcpConfig(file, key, entry, cwd, done) {
6106
6359
  let json3 = {};
6107
- if (import_node_fs5.default.existsSync(file)) {
6360
+ if (import_node_fs6.default.existsSync(file)) {
6108
6361
  try {
6109
- json3 = JSON.parse(import_node_fs5.default.readFileSync(file, "utf8"));
6362
+ json3 = JSON.parse(import_node_fs6.default.readFileSync(file, "utf8"));
6110
6363
  } catch {
6111
- import_node_fs5.default.copyFileSync(file, file + ".bak");
6364
+ import_node_fs6.default.copyFileSync(file, file + ".bak");
6112
6365
  done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
6113
6366
  json3 = {};
6114
6367
  }
6115
6368
  } else {
6116
- import_node_fs5.default.mkdirSync(import_node_path5.default.dirname(file), { recursive: true });
6369
+ import_node_fs6.default.mkdirSync(import_node_path6.default.dirname(file), { recursive: true });
6117
6370
  }
6118
6371
  const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
6119
6372
  const existed = "buoy" in servers;
6120
6373
  servers.buoy = entry;
6121
6374
  json3[key] = servers;
6122
- import_node_fs5.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
6375
+ import_node_fs6.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
6123
6376
  done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
6124
6377
  }
6125
6378
  var GUIDE_HEADING = "# Debugging with Buoy";
6126
6379
  var DEBUGGING_GUIDE = `${GUIDE_HEADING}
6127
6380
 
6128
- This app's runtime is inspectable while it runs: the Buoy MCP tools read live
6129
- state straight from the device. Route by symptom before reading source:
6130
-
6131
- - \`get_triage\` \u2014 START HERE. One call: device, recent console errors and
6132
- crashes, recent network requests, storage keys, and what's on screen. It
6133
- usually names the problem, and always names the tool to drill into next.
6134
- - \`run_flow\` \u2014 reproduce a bug or verify a fix in ONE call: a scripted
6135
- tap/type/wait/assert sequence that returns the final screen plus the network
6136
- and console output it produced. Use it instead of alternating
6137
- \`describe_screen\` and \`tap_element\` a dozen times.
6138
- - \`get_events\` \u2014 the universal activity timeline (network, state changes,
6139
- storage writes, routes, renders). Check it BEFORE driving the UI to
6140
- reproduce a bug \u2014 the failure is usually already recorded.
6141
- - \`get_network_requests\` \u2014 API data wrong or calls failing \u2192 read the actual
6142
- request + response bodies instead of guessing from code. Boot-time requests
6143
- are captured too.
6144
- - \`get_console\` \u2014 crashes and errors, including FATAL uncaught JS errors
6145
- (tagged \`[FATAL]\`, with the component that crashed). Readable even after the
6146
- app dies, so a redboxed app still tells you why.
6147
- - \`get_storage\` \u2014 data wrong or missing after an app restart \u2192 read the
6148
- persisted values FIRST.
6149
- - \`reload_app\` \u2014 recover a crashed app (the other tools stop answering until
6150
- you do) or pick up a change Fast Refresh missed.
6151
- - \`get_snapshot\` \u2014 full current state of any one tool when the compact views
6152
- aren't enough.
6153
-
6154
- Runtime facts beat guesses \u2014 and after you edit, prove the fix on the device:
6155
- reload, then re-drive EVERY screen the symptom could show up on and assert the
6156
- corrected value. Fixes usually fail by being incomplete, not by being wrong.
6157
-
6158
- ## Don't report a fix you haven't watched work
6159
-
6160
- Saying "fixed" is a claim about how the app behaves, so check it the same way
6161
- you'd check anything else about the app \u2014 by looking at it. Re-run the exact
6162
- interaction that was broken and report what the numbers did:
6163
-
6164
- - \`measure_renders\` with the same \`steps\` and \`compareToPrevious: true\` for a
6165
- performance change \u2014 it prints the before/after delta and flags a change that
6166
- made things worse.
6167
- - \`run_flow\` re-driving the broken screen for a behaviour change.
6168
-
6169
- Two things this catches that reading cannot: a fix that is right but incomplete
6170
- (the value was wrong in three places and you corrected one), and a fix that
6171
- moved the cost instead of removing it \u2014 the usual failure mode for re-render
6172
- work, where a page gets faster to type in and slower to tap.
6173
-
6174
- If the app changed since anything was last checked, Buoy will say so in a tool
6175
- result. Whether that's worth acting on is your call \u2014 a doc edit isn't worth a
6176
- device check, a state-management change is. Set \`BUOY_VERIFY\` in the MCP config
6177
- to \`always\` (repeat until checked) or \`never\` (silent) to change how often it
6178
- mentions it.
6381
+ Use Buoy to inspect a connected app when runtime evidence helps the task.
6382
+ Complete account, tool integration and connection setup first. MCP data and
6383
+ action access requires Pro. Follow the user's scope and repository instructions.
6384
+
6385
+ - \`get_triage\`: summarize available device and tool observations, then choose
6386
+ a relevant tool. Missing data is not proof that no problem occurred.
6387
+ - \`run_flow\`: run supported tap, type, wait and assertion steps to reproduce
6388
+ a failure or check a change. Review the flow before running it.
6389
+ - \`get_events\`: inspect retained events from connected tools.
6390
+ - \`get_network_requests\`: inspect captured requests and responses. Capture
6391
+ depends on installed hooks, transport and when recording began.
6392
+ - \`network_conditions\`: take the app offline or add latency to check error
6393
+ and retry paths. Development builds only. Restore \`normal\` when finished.
6394
+ - \`get_console\`: inspect retained console and error output. A terminated or
6395
+ disconnected app may not provide new data.
6396
+ - \`get_storage\`: inspect registered storage backends and available keys.
6397
+ - \`reload_app\`: reload where supported. Confirm the device remains connected;
6398
+ a reload does not guarantee recovery from a crash.
6399
+ - \`get_snapshot\`: inspect the available state of a specific tool.
6400
+
6401
+ After a behavior change, repeat the failing interaction when the environment
6402
+ allows it. Use relevant tests and report what was actually checked. For a
6403
+ performance change, compare \`measure_renders\` runs with the same steps and
6404
+ \`compareToPrevious: true\`; inspect the measurements and their limitations.
6405
+ Use \`run_flow\` for supported UI checks. Do not claim a fix was verified when
6406
+ only source review or a build was performed.
6407
+
6408
+ \`BUOY_VERIFY\` controls verification reminders: \`auto\` uses edit-based
6409
+ reminders, \`always\` repeats until checked, and \`never\` disables them.
6410
+ A reminder is not a test result. Choose checks appropriate to the change;
6411
+ prose-only edits do not require running device actions.
6179
6412
  `;
6180
6413
  function appendDebuggingGuide(file, cwd, done) {
6181
6414
  let existing = "";
6182
- if (import_node_fs5.default.existsSync(file)) {
6415
+ if (import_node_fs6.default.existsSync(file)) {
6183
6416
  try {
6184
- existing = import_node_fs5.default.readFileSync(file, "utf8");
6417
+ existing = import_node_fs6.default.readFileSync(file, "utf8");
6185
6418
  } catch {
6186
6419
  done.push(`\u26A0 could not read ${rel(file, cwd)} \u2014 skipped the debugging guide`);
6187
6420
  return;
6188
6421
  }
6189
6422
  if (existing.includes(GUIDE_HEADING)) {
6190
- done.push(`${rel(file, cwd)} already has the "${GUIDE_HEADING}" block`);
6423
+ done.push(`${rel(file, cwd)} already has the "${GUIDE_HEADING}" block; preserved existing text. Review it against the current bundled guidance before relying on older instructions.`);
6191
6424
  return;
6192
6425
  }
6193
6426
  }
6194
6427
  const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
6195
6428
  try {
6196
- import_node_fs5.default.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
6429
+ import_node_fs6.default.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
6197
6430
  done.push(`${existing ? "appended" : "created"} "${GUIDE_HEADING}" block in ${rel(file, cwd)}`);
6198
6431
  } catch {
6199
6432
  done.push(`\u26A0 could not write ${rel(file, cwd)} \u2014 skipped the debugging guide`);
6200
6433
  }
6201
6434
  }
6202
6435
  function installSkills(skillSrcDir, cwd, done) {
6203
- if (!import_node_fs5.default.existsSync(skillSrcDir)) {
6436
+ if (!import_node_fs6.default.existsSync(skillSrcDir)) {
6204
6437
  done.push("\u26A0 no bundled skills found \u2014 skipped");
6205
6438
  return;
6206
6439
  }
6207
- const skills = import_node_fs5.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs5.default.existsSync(import_node_path5.default.join(skillSrcDir, e.name, "SKILL.md")));
6440
+ const skills = import_node_fs6.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs6.default.existsSync(import_node_path6.default.join(skillSrcDir, e.name, "SKILL.md")));
6208
6441
  if (skills.length === 0) {
6209
6442
  done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
6210
6443
  return;
6211
6444
  }
6212
6445
  for (const skill of skills) {
6213
- const from = import_node_path5.default.join(skillSrcDir, skill.name);
6214
- const to = import_node_path5.default.join(cwd, ".claude", "skills", skill.name);
6446
+ const from = import_node_path6.default.join(skillSrcDir, skill.name);
6447
+ const to = import_node_path6.default.join(cwd, ".claude", "skills", skill.name);
6215
6448
  copyDir(from, to);
6216
- done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path5.default.join(to, "SKILL.md"), cwd)}`);
6449
+ done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path6.default.join(to, "SKILL.md"), cwd)}`);
6217
6450
  }
6218
6451
  }
6219
6452
  function copyDir(from, to) {
6220
- import_node_fs5.default.mkdirSync(to, { recursive: true });
6221
- for (const entry of import_node_fs5.default.readdirSync(from, { withFileTypes: true })) {
6222
- const s = import_node_path5.default.join(from, entry.name);
6223
- const d = import_node_path5.default.join(to, entry.name);
6453
+ import_node_fs6.default.mkdirSync(to, { recursive: true });
6454
+ for (const entry of import_node_fs6.default.readdirSync(from, { withFileTypes: true })) {
6455
+ const s = import_node_path6.default.join(from, entry.name);
6456
+ const d = import_node_path6.default.join(to, entry.name);
6224
6457
  if (entry.isDirectory()) copyDir(s, d);
6225
- else import_node_fs5.default.copyFileSync(s, d);
6458
+ else import_node_fs6.default.copyFileSync(s, d);
6226
6459
  }
6227
6460
  }
6228
6461
  function rel(file, cwd) {
6229
- const r = import_node_path5.default.relative(cwd, file);
6462
+ const r = import_node_path6.default.relative(cwd, file);
6230
6463
  return r && !r.startsWith("..") ? r : file;
6231
6464
  }
6232
6465