@buoy-gg/mcp 7.0.21 → 7.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JQ5GZ4LG.mjs → chunk-SGRL6UKT.mjs} +1391 -110
- package/dist/cli.js +1391 -110
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +167 -11
- package/dist/index.d.ts +167 -11
- package/dist/index.js +1391 -110
- package/dist/index.mjs +1 -1
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -72,7 +72,12 @@ var import_sync_broker = require("@buoy-gg/sync-broker");
|
|
|
72
72
|
var BROKER_PORT = import_sync_broker.DEFAULT_BROKER_PORT;
|
|
73
73
|
var BROKER_URL = `http://localhost:${BROKER_PORT}`;
|
|
74
74
|
var DEFAULT_ACTION_TIMEOUT_MS = 15e3;
|
|
75
|
+
var UNRESPONSIVE_ACTION_TIMEOUT_MS = 2500;
|
|
76
|
+
var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS = 1200;
|
|
77
|
+
var RECOVERY_PROBE_MS = 2e4;
|
|
78
|
+
var UNRESPONSIVE_AFTER_MS = 3e4;
|
|
75
79
|
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 5e3;
|
|
80
|
+
var DEVICE_RETENTION_MS = 15 * 6e4;
|
|
76
81
|
var DASHBOARD_NAME = "Dashboard";
|
|
77
82
|
var ALL_DEVICES = "All";
|
|
78
83
|
|
|
@@ -97,6 +102,16 @@ var SyncClient = class {
|
|
|
97
102
|
this.pendingActions = /* @__PURE__ */ new Map();
|
|
98
103
|
/** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
|
|
99
104
|
this.snapshotWaiters = /* @__PURE__ */ new Map();
|
|
105
|
+
/** When each cached snapshot arrived, keyed by snapKey (staleness reporting). */
|
|
106
|
+
this.snapshotAt = /* @__PURE__ */ new Map();
|
|
107
|
+
/** Liveness per device — see {@link noteTimeout}. */
|
|
108
|
+
this.health = /* @__PURE__ */ new Map();
|
|
109
|
+
/** Dev-server origin per device — see {@link noteDevServerOrigin}. */
|
|
110
|
+
this.devServerOrigins = /* @__PURE__ */ new Map();
|
|
111
|
+
/** Devices whose origin probe already ran (a production bundle has none). */
|
|
112
|
+
this.originProbed = /* @__PURE__ */ new Set();
|
|
113
|
+
/** Last time each device appeared in the broker's device list. */
|
|
114
|
+
this.lastSeen = /* @__PURE__ */ new Map();
|
|
100
115
|
this.port = opts.port ?? BROKER_PORT;
|
|
101
116
|
this.url = opts.url ?? (opts.port ? `http://localhost:${opts.port}` : BROKER_URL);
|
|
102
117
|
this.actionTimeoutMs = opts.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
@@ -156,13 +171,19 @@ var SyncClient = class {
|
|
|
156
171
|
});
|
|
157
172
|
socket.on("devtool-capabilities", (msg) => {
|
|
158
173
|
this.capabilities.set(msg.persistentDeviceId, msg);
|
|
174
|
+
void this.learnDevServerOrigin(msg.persistentDeviceId);
|
|
159
175
|
});
|
|
160
176
|
socket.on("all-devices-update", (list) => {
|
|
161
|
-
if (Array.isArray(list))
|
|
177
|
+
if (!Array.isArray(list)) return;
|
|
178
|
+
this.devices = list;
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
for (const d of list) if (d.deviceId) this.lastSeen.set(d.deviceId, now);
|
|
181
|
+
this.pruneStaleDevices();
|
|
162
182
|
});
|
|
163
183
|
socket.on("devtool-sync", (msg) => {
|
|
164
184
|
const key = snapKey(msg.persistentDeviceId, msg.toolId);
|
|
165
185
|
this.snapshots.set(key, msg.payload);
|
|
186
|
+
this.snapshotAt.set(key, Date.now());
|
|
166
187
|
const waiters = this.snapshotWaiters.get(key);
|
|
167
188
|
if (waiters && waiters.length) {
|
|
168
189
|
this.snapshotWaiters.set(key, []);
|
|
@@ -178,6 +199,37 @@ var SyncClient = class {
|
|
|
178
199
|
});
|
|
179
200
|
});
|
|
180
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Drop everything remembered about devices that have been gone a while.
|
|
204
|
+
*
|
|
205
|
+
* Deliberately NOT on disconnect: a crashed app often drops off the broker,
|
|
206
|
+
* and its cached console snapshot is the crash evidence — throwing that away
|
|
207
|
+
* the instant it disconnects would delete the answer. But device ids change
|
|
208
|
+
* with every simulator erase and fresh dev client, and each one can hold
|
|
209
|
+
* multi-MB snapshots, so a long-lived server has to forget eventually.
|
|
210
|
+
*/
|
|
211
|
+
pruneStaleDevices() {
|
|
212
|
+
const cutoff = Date.now() - DEVICE_RETENTION_MS;
|
|
213
|
+
for (const [deviceId, seen] of this.lastSeen) {
|
|
214
|
+
if (seen > cutoff) continue;
|
|
215
|
+
if (this.devices.some((d) => d.deviceId === deviceId)) continue;
|
|
216
|
+
this.lastSeen.delete(deviceId);
|
|
217
|
+
this.capabilities.delete(deviceId);
|
|
218
|
+
this.health.delete(deviceId);
|
|
219
|
+
this.devServerOrigins.delete(deviceId);
|
|
220
|
+
this.originProbed.delete(deviceId);
|
|
221
|
+
const prefix = `${deviceId}::`;
|
|
222
|
+
for (const key of [...this.snapshots.keys()]) {
|
|
223
|
+
if (key.startsWith(prefix)) {
|
|
224
|
+
this.snapshots.delete(key);
|
|
225
|
+
this.snapshotAt.delete(key);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const key of [...this.watching.keys()]) {
|
|
229
|
+
if (key.startsWith(prefix)) this.watching.delete(key);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
181
233
|
/** Disconnect and tear down a spawned broker, if any. */
|
|
182
234
|
async stop() {
|
|
183
235
|
for (const [, p] of this.pendingActions) {
|
|
@@ -185,6 +237,8 @@ var SyncClient = class {
|
|
|
185
237
|
p.reject(new Error("SyncClient stopped"));
|
|
186
238
|
}
|
|
187
239
|
this.pendingActions.clear();
|
|
240
|
+
for (const [, waiters] of this.snapshotWaiters) for (const w of waiters) w(void 0);
|
|
241
|
+
this.snapshotWaiters.clear();
|
|
188
242
|
this.socket?.close();
|
|
189
243
|
this.socket = null;
|
|
190
244
|
if (this.broker) {
|
|
@@ -205,6 +259,7 @@ var SyncClient = class {
|
|
|
205
259
|
platform: d.platform,
|
|
206
260
|
isConnected: d.isConnected !== false,
|
|
207
261
|
isPro: d.isPro === true,
|
|
262
|
+
tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
|
|
208
263
|
tools: caps?.tools ?? []
|
|
209
264
|
};
|
|
210
265
|
});
|
|
@@ -228,11 +283,19 @@ var SyncClient = class {
|
|
|
228
283
|
resolveDevice(deviceId) {
|
|
229
284
|
if (deviceId) return deviceId;
|
|
230
285
|
const connected = this.connectedDevices();
|
|
231
|
-
if (connected.length === 1) return connected[0].deviceId;
|
|
232
286
|
if (connected.length === 0) {
|
|
233
287
|
throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.");
|
|
234
288
|
}
|
|
235
|
-
|
|
289
|
+
if (connected.length === 1) return connected[0].deviceId;
|
|
290
|
+
const usable = connected.filter((d) => d.tools.length > 0);
|
|
291
|
+
if (usable.length === 1) return usable[0].deviceId;
|
|
292
|
+
if (usable.length === 0) {
|
|
293
|
+
const names = connected.map((d) => d.deviceName).join(", ");
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Connected devices (${names}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const list = usable.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
|
|
236
299
|
throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${list}`);
|
|
237
300
|
}
|
|
238
301
|
/**
|
|
@@ -246,17 +309,153 @@ var SyncClient = class {
|
|
|
246
309
|
* is blocked.
|
|
247
310
|
*/
|
|
248
311
|
requireProDevice(deviceId) {
|
|
249
|
-
const
|
|
312
|
+
const tierNote = (dev2) => {
|
|
313
|
+
if (!dev2) return "";
|
|
314
|
+
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.`;
|
|
315
|
+
};
|
|
316
|
+
const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
|
|
317
|
+
dev2
|
|
318
|
+
)} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
|
|
250
319
|
if (deviceId === ALL_DEVICES) {
|
|
251
320
|
if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
|
|
252
321
|
return ALL_DEVICES;
|
|
253
322
|
}
|
|
254
323
|
const target = this.resolveDevice(deviceId);
|
|
255
324
|
const dev = this.connectedDevices().find((d) => d.deviceId === target);
|
|
256
|
-
if (!dev?.isPro) throw new Error(proMsg(dev
|
|
325
|
+
if (!dev?.isPro) throw new Error(proMsg(dev));
|
|
257
326
|
return target;
|
|
258
327
|
}
|
|
259
328
|
// ----------------------------------------------------------------
|
|
329
|
+
// Device liveness
|
|
330
|
+
// ----------------------------------------------------------------
|
|
331
|
+
healthOf(deviceId) {
|
|
332
|
+
let h = this.health.get(deviceId);
|
|
333
|
+
if (!h) {
|
|
334
|
+
h = { consecutiveTimeouts: 0, lastOkAt: 0, lastFullBudgetAt: 0 };
|
|
335
|
+
this.health.set(deviceId, h);
|
|
336
|
+
}
|
|
337
|
+
return h;
|
|
338
|
+
}
|
|
339
|
+
/** The device answered — it's alive. */
|
|
340
|
+
noteOk(deviceId) {
|
|
341
|
+
const h = this.healthOf(deviceId);
|
|
342
|
+
h.consecutiveTimeouts = 0;
|
|
343
|
+
h.lastOkAt = Date.now();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The device didn't answer. A crashed app never answers again, so after the
|
|
347
|
+
* first miss we stop paying the full timeout on every subsequent call.
|
|
348
|
+
*/
|
|
349
|
+
noteTimeout(deviceId) {
|
|
350
|
+
this.healthOf(deviceId).consecutiveTimeouts += 1;
|
|
351
|
+
}
|
|
352
|
+
/** True once a device has missed a call and hasn't answered since. */
|
|
353
|
+
isUnresponsive(deviceId) {
|
|
354
|
+
return this.healthOf(deviceId).consecutiveTimeouts > 0;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Full timeout normally; a short one for a device already not answering.
|
|
358
|
+
*
|
|
359
|
+
* With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
|
|
360
|
+
* full budget again. Without it the flag is self-sustaining — a device whose
|
|
361
|
+
* work legitimately takes longer than the short budget (a big snapshot, a
|
|
362
|
+
* slow simulator) can never answer, so it can never clear the flag, and every
|
|
363
|
+
* later reply is a confident "most likely CRASHED" about a healthy app.
|
|
364
|
+
*/
|
|
365
|
+
timeoutFor(deviceId) {
|
|
366
|
+
if (!this.isUnresponsive(deviceId)) return this.actionTimeoutMs;
|
|
367
|
+
const h = this.healthOf(deviceId);
|
|
368
|
+
if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
|
|
369
|
+
h.lastFullBudgetAt = Date.now();
|
|
370
|
+
return this.actionTimeoutMs;
|
|
371
|
+
}
|
|
372
|
+
return UNRESPONSIVE_ACTION_TIMEOUT_MS;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Plain-language diagnosis for a device that isn't answering, including the
|
|
376
|
+
* app's last words if the console tool got a crash flush out in time.
|
|
377
|
+
*
|
|
378
|
+
* A silent timeout is the worst possible answer here: the app is on a redbox,
|
|
379
|
+
* the reason is already captured, and without this the caller reads "timed
|
|
380
|
+
* out" and keeps retrying the same dead device.
|
|
381
|
+
*/
|
|
382
|
+
unresponsiveNote(deviceId) {
|
|
383
|
+
const h = this.healthOf(deviceId);
|
|
384
|
+
const since = h.lastOkAt ? Math.round((Date.now() - h.lastOkAt) / 1e3) : null;
|
|
385
|
+
const crashed = h.consecutiveTimeouts >= 2 || h.lastOkAt === 0 || Date.now() - h.lastOkAt > UNRESPONSIVE_AFTER_MS;
|
|
386
|
+
const lines = [
|
|
387
|
+
`The app on "${deviceId}" is not responding (${h.consecutiveTimeouts} call${h.consecutiveTimeouts === 1 ? "" : "s"} unanswered${since != null ? `, last answered ${since}s ago` : ""}).`,
|
|
388
|
+
crashed ? "It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded." : "It may be busy, mid-reload, or crashing."
|
|
389
|
+
];
|
|
390
|
+
const fatal = this.lastFatalFor(deviceId);
|
|
391
|
+
if (fatal) lines.push(`Last captured error: ${fatal}`);
|
|
392
|
+
lines.push(
|
|
393
|
+
fatal ? "Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover." : "Try get_console (served from the last sync) for the app's last words, then reload_app to recover."
|
|
394
|
+
);
|
|
395
|
+
return lines.join(" ");
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The newest error line the console tool synced for this device, if any.
|
|
399
|
+
* Reads the cached console snapshot rather than the device (which, by the
|
|
400
|
+
* time this matters, is not answering).
|
|
401
|
+
*/
|
|
402
|
+
lastFatalFor(deviceId) {
|
|
403
|
+
const snap = this.snapshots.get(snapKey(deviceId, "console"));
|
|
404
|
+
const entries = snap?.entries;
|
|
405
|
+
if (!Array.isArray(entries)) return null;
|
|
406
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
407
|
+
const e = entries[i];
|
|
408
|
+
if (e?.fatal || e?.level === "error") {
|
|
409
|
+
const msg = (e.message ?? "").split("\n")[0];
|
|
410
|
+
if (msg) return msg.length > 300 ? `${msg.slice(0, 300)}\u2026` : msg;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
/** Age of the cached snapshot for a tool, in ms (null = never received). */
|
|
416
|
+
snapshotAgeMs(toolId, deviceId) {
|
|
417
|
+
const at = this.snapshotAt.get(snapKey(deviceId, toolId));
|
|
418
|
+
return at ? Date.now() - at : null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Remember where a device's bundle came from, reported by core's `app`
|
|
422
|
+
* adapter while the app is healthy. It is the only way to reload an app that
|
|
423
|
+
* has crashed: the in-app reload needs the React tree that the crash
|
|
424
|
+
* destroyed, but the dev server's reload is handled natively.
|
|
425
|
+
*/
|
|
426
|
+
noteDevServerOrigin(deviceId, origin) {
|
|
427
|
+
if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
|
|
428
|
+
this.devServerOrigins.set(deviceId, origin);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Ask a freshly-announced device where its bundle came from, once.
|
|
433
|
+
*
|
|
434
|
+
* Deliberately eager: this is the piece of information that can ONLY be
|
|
435
|
+
* collected while the app is healthy, and is only ever needed after it has
|
|
436
|
+
* stopped answering. One cheap round-trip per device buys the ability to
|
|
437
|
+
* recover a crashed app instead of telling the caller to go do it by hand.
|
|
438
|
+
*/
|
|
439
|
+
async learnDevServerOrigin(deviceId) {
|
|
440
|
+
if (this.devServerOrigins.has(deviceId) || this.originProbed.has(deviceId)) return;
|
|
441
|
+
this.originProbed.add(deviceId);
|
|
442
|
+
const hasApp = this.capabilities.get(deviceId)?.tools?.some((t) => t.toolId === "app");
|
|
443
|
+
if (!hasApp) return;
|
|
444
|
+
try {
|
|
445
|
+
const { payload } = await this.getSnapshot("app", deviceId, { timeoutMs: 4e3, silent: true });
|
|
446
|
+
this.noteDevServerOrigin(deviceId, payload?.devServerOrigin);
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/** The dev-server origin last reported by this device, if any. */
|
|
451
|
+
devServerOrigin(deviceId) {
|
|
452
|
+
const known = this.devServerOrigins.get(deviceId);
|
|
453
|
+
if (known) return known;
|
|
454
|
+
const snap = this.snapshots.get(snapKey(deviceId, "app"));
|
|
455
|
+
if (typeof snap?.devServerOrigin === "string") return snap.devServerOrigin;
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
// ----------------------------------------------------------------
|
|
260
459
|
// Watch + snapshots
|
|
261
460
|
// ----------------------------------------------------------------
|
|
262
461
|
/**
|
|
@@ -327,13 +526,36 @@ var SyncClient = class {
|
|
|
327
526
|
const target = this.resolveDevice(deviceId);
|
|
328
527
|
const key = snapKey(target, toolId);
|
|
329
528
|
this.ensureWatching(toolId, target);
|
|
330
|
-
|
|
529
|
+
let budget = opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS;
|
|
530
|
+
if (opts.timeoutMs === void 0 && this.isUnresponsive(target)) {
|
|
531
|
+
const h = this.healthOf(target);
|
|
532
|
+
if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
|
|
533
|
+
h.lastFullBudgetAt = Date.now();
|
|
534
|
+
} else {
|
|
535
|
+
budget = UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const next = this.waitForSnapshot(key, budget);
|
|
331
539
|
this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
|
|
332
540
|
const payload = await next;
|
|
333
|
-
if (payload !== void 0)
|
|
541
|
+
if (payload !== void 0) {
|
|
542
|
+
if (!opts.silent) this.noteOk(target);
|
|
543
|
+
return { deviceId: target, payload };
|
|
544
|
+
}
|
|
545
|
+
if (!opts.silent) this.noteTimeout(target);
|
|
334
546
|
const cached = this.snapshots.get(key);
|
|
335
|
-
if (cached !== void 0)
|
|
336
|
-
|
|
547
|
+
if (cached !== void 0) {
|
|
548
|
+
return { deviceId: target, payload: cached, stale: this.staleNote(toolId, target) };
|
|
549
|
+
}
|
|
550
|
+
throw new Error(
|
|
551
|
+
`Timed out waiting for "${toolId}" state from ${target} (is the tool active?). ${this.unresponsiveNote(target)}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
/** Header line for data served from cache because the device went quiet. */
|
|
555
|
+
staleNote(toolId, deviceId) {
|
|
556
|
+
const age = this.snapshotAgeMs(toolId, deviceId);
|
|
557
|
+
const ageText = age == null ? "an earlier sync" : `${Math.round(age / 1e3)}s ago`;
|
|
558
|
+
return `\u26A0\uFE0F Served from the last sync (${ageText}) \u2014 ${this.unresponsiveNote(deviceId)}`;
|
|
337
559
|
}
|
|
338
560
|
waitForSnapshot(key, timeoutMs) {
|
|
339
561
|
return new Promise((resolve) => {
|
|
@@ -362,11 +584,16 @@ var SyncClient = class {
|
|
|
362
584
|
const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
|
|
363
585
|
if (!this.socket) throw new Error("SyncClient is not connected");
|
|
364
586
|
const requestId = (0, import_node_crypto.randomUUID)();
|
|
365
|
-
const timeoutMs = opts.timeoutMs ?? this.
|
|
587
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target);
|
|
366
588
|
const result = await new Promise((resolve, reject) => {
|
|
367
589
|
const timer = setTimeout(() => {
|
|
368
590
|
this.pendingActions.delete(requestId);
|
|
369
|
-
|
|
591
|
+
if (target !== ALL_DEVICES && !opts.silent) this.noteTimeout(target);
|
|
592
|
+
reject(
|
|
593
|
+
new Error(
|
|
594
|
+
`Action "${toolId}.${action}" got no answer in ${timeoutMs}ms. ` + (target === ALL_DEVICES ? "No device responded." : this.unresponsiveNote(target))
|
|
595
|
+
)
|
|
596
|
+
);
|
|
370
597
|
}, timeoutMs);
|
|
371
598
|
this.pendingActions.set(requestId, { resolve, reject, timer });
|
|
372
599
|
this.socket.emit("devtool-action", {
|
|
@@ -377,6 +604,7 @@ var SyncClient = class {
|
|
|
377
604
|
params: params ?? {}
|
|
378
605
|
});
|
|
379
606
|
});
|
|
607
|
+
if (target !== ALL_DEVICES && !opts.silent) this.noteOk(target);
|
|
380
608
|
if (!result.ok) {
|
|
381
609
|
throw new Error(result.error || `Action "${toolId}.${action}" failed on device`);
|
|
382
610
|
}
|
|
@@ -408,7 +636,7 @@ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
408
636
|
|
|
409
637
|
// src/server.ts
|
|
410
638
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
411
|
-
var
|
|
639
|
+
var import_zod22 = require("zod");
|
|
412
640
|
|
|
413
641
|
// src/tools/generic.ts
|
|
414
642
|
var import_zod = require("zod");
|
|
@@ -419,7 +647,7 @@ var import_node_os = __toESM(require("os"));
|
|
|
419
647
|
var import_node_path = __toESM(require("path"));
|
|
420
648
|
|
|
421
649
|
// src/version.ts
|
|
422
|
-
var VERSION = "7.0.
|
|
650
|
+
var VERSION = "7.0.23" ? "7.0.23" : "0.0.0-dev";
|
|
423
651
|
var PACKAGE_NAME = "@buoy-gg/mcp";
|
|
424
652
|
|
|
425
653
|
// src/update/checkForUpdate.ts
|
|
@@ -510,11 +738,13 @@ function registerGenericTools(server, sync) {
|
|
|
510
738
|
...updateNotice ? { updateAvailable: updateNotice } : {}
|
|
511
739
|
});
|
|
512
740
|
}
|
|
513
|
-
const
|
|
741
|
+
const connected = devices.filter((d) => d.isConnected);
|
|
742
|
+
const anyPro = connected.some((d) => d.isPro);
|
|
743
|
+
const anySignedIn = connected.some((d) => d.tier !== "anonymous");
|
|
514
744
|
return json({
|
|
515
745
|
devices,
|
|
516
746
|
...anyPro ? {} : {
|
|
517
|
-
hint: "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them."
|
|
747
|
+
hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them." : "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them."
|
|
518
748
|
},
|
|
519
749
|
...updateNotice ? { updateAvailable: updateNotice } : {}
|
|
520
750
|
});
|
|
@@ -593,6 +823,14 @@ var PERF_TOOL_ID = "perf-monitor";
|
|
|
593
823
|
|
|
594
824
|
// src/tools/packs/perfRanking.ts
|
|
595
825
|
var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
826
|
+
var WARMUP_CASE_NAME = "__batch_warmup__";
|
|
827
|
+
var THERMAL_MARK = {
|
|
828
|
+
nominal: "\u{1F7E2} nominal",
|
|
829
|
+
fair: "\u{1F7E1} fair",
|
|
830
|
+
serious: "\u{1F7E0} serious",
|
|
831
|
+
critical: "\u{1F534} critical",
|
|
832
|
+
unknown: "\u2014"
|
|
833
|
+
};
|
|
596
834
|
var FALLBACK = {
|
|
597
835
|
perCaseDurationMs: 5e3,
|
|
598
836
|
settleMs: 600,
|
|
@@ -652,6 +890,7 @@ function buildAutomationConfig(input, base = {}) {
|
|
|
652
890
|
runsPerCase,
|
|
653
891
|
coolDownMs,
|
|
654
892
|
discardWarmupRuns,
|
|
893
|
+
discardWarmupCase: input.discardWarmupCase ?? base.discardWarmupCase ?? true,
|
|
655
894
|
shuffleCases,
|
|
656
895
|
reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
|
|
657
896
|
captureRenders: input.captureRenders ?? base.captureRenders ?? true
|
|
@@ -696,7 +935,9 @@ function computeFlags(r) {
|
|
|
696
935
|
function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
|
|
697
936
|
const entries = index.filter((e) => e.batchId === batchId);
|
|
698
937
|
const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
|
|
699
|
-
const indexes =
|
|
938
|
+
const indexes = Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort(
|
|
939
|
+
(a, b) => a - b
|
|
940
|
+
);
|
|
700
941
|
const medianForCase = (batchIndex) => {
|
|
701
942
|
const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
|
|
702
943
|
if (runs.length === 0) return void 0;
|
|
@@ -706,7 +947,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
|
|
|
706
947
|
const rows = indexes.map((batchIndex) => {
|
|
707
948
|
const median = medianForCase(batchIndex);
|
|
708
949
|
const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
|
|
709
|
-
const caseName = casesOrder?.[batchIndex]?.name ??
|
|
950
|
+
const caseName = median?.name ?? casesOrder?.[batchIndex]?.name ?? `case ${batchIndex + 1}`;
|
|
710
951
|
const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
|
|
711
952
|
const failed = Boolean(failureReason) || !median;
|
|
712
953
|
const base = {
|
|
@@ -725,11 +966,13 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
|
|
|
725
966
|
deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
|
|
726
967
|
deltaUiFps: null,
|
|
727
968
|
deltaJsFps: null,
|
|
969
|
+
...median?.thermalState ? { thermalState: median.thermalState } : {},
|
|
728
970
|
renderCommits: num(median?.renderCommits),
|
|
971
|
+
renderWasted: median?.renderWasted,
|
|
729
972
|
topRenderers: median?.topRenderers ?? []
|
|
730
973
|
};
|
|
731
974
|
return { ...base, flags: failed ? [] : computeFlags(base) };
|
|
732
|
-
});
|
|
975
|
+
}).filter((r) => r.caseName !== WARMUP_CASE_NAME).map((r, i) => ({ ...r, batchIndex: i }));
|
|
733
976
|
const baseline = rows[0];
|
|
734
977
|
if (baseline) {
|
|
735
978
|
for (const r of rows) {
|
|
@@ -783,18 +1026,33 @@ function formatRankingMarkdown(ranking) {
|
|
|
783
1026
|
`**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`
|
|
784
1027
|
);
|
|
785
1028
|
lines.push("");
|
|
786
|
-
|
|
787
|
-
|
|
1029
|
+
const anyThermal = ranking.rows.some((r) => r.thermalState);
|
|
1030
|
+
const thermalHead = anyThermal ? " Thermal |" : "";
|
|
1031
|
+
const thermalRule = anyThermal ? "-------|" : "";
|
|
1032
|
+
lines.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${thermalHead} Flags |`);
|
|
1033
|
+
lines.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${thermalRule}-------|`);
|
|
788
1034
|
for (const r of ranking.rows) {
|
|
1035
|
+
const thermalCell = anyThermal ? ` ${THERMAL_MARK[r.thermalState ?? "unknown"] ?? "\u2014"} |` : "";
|
|
789
1036
|
if (r.failed) {
|
|
790
|
-
lines.push(
|
|
1037
|
+
lines.push(
|
|
1038
|
+
`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${thermalCell} \u274C FAILED (${r.failureReason}) |`
|
|
1039
|
+
);
|
|
791
1040
|
continue;
|
|
792
1041
|
}
|
|
793
1042
|
const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
|
|
794
1043
|
lines.push(
|
|
795
1044
|
`| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
|
|
796
1045
|
r.deltaJsFps
|
|
797
|
-
)} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)}
|
|
1046
|
+
)} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} |${thermalCell} ${flagCell} |`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const throttled = ranking.rows.filter(
|
|
1050
|
+
(r) => r.thermalState === "serious" || r.thermalState === "critical"
|
|
1051
|
+
);
|
|
1052
|
+
if (throttled.length) {
|
|
1053
|
+
lines.push("");
|
|
1054
|
+
lines.push(
|
|
1055
|
+
`**\u{1F525} Thermally throttled: ${throttled.map((r) => r.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`
|
|
798
1056
|
);
|
|
799
1057
|
}
|
|
800
1058
|
lines.push("");
|
|
@@ -816,13 +1074,18 @@ function formatRankingMarkdown(ranking) {
|
|
|
816
1074
|
);
|
|
817
1075
|
}
|
|
818
1076
|
const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
|
|
819
|
-
const top = r.topRenderers.map(
|
|
1077
|
+
const top = r.topRenderers.map(
|
|
1078
|
+
(t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms${t.cause ? `, ${t.cause}` : ""})`
|
|
1079
|
+
).join(", ");
|
|
820
1080
|
const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
|
|
821
|
-
|
|
1081
|
+
const wasted = r.renderWasted != null ? `${r.renderWasted} wasted` : "";
|
|
1082
|
+
return `- **${r.caseName}** \u2014 ${[commits, wasted, top].filter(Boolean).join(": ")}`;
|
|
822
1083
|
});
|
|
823
1084
|
if (renderLines.length) {
|
|
824
1085
|
lines.push("");
|
|
825
|
-
lines.push(
|
|
1086
|
+
lines.push(
|
|
1087
|
+
"**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream."
|
|
1088
|
+
);
|
|
826
1089
|
lines.push(...renderLines);
|
|
827
1090
|
}
|
|
828
1091
|
lines.push("");
|
|
@@ -845,7 +1108,20 @@ function compareReportStats(a, b) {
|
|
|
845
1108
|
cpuUsage: ch(a.stats.cpuUsage, b.stats.cpuUsage),
|
|
846
1109
|
memoryUsage: ch(a.stats.memoryUsage, b.stats.memoryUsage),
|
|
847
1110
|
jsJankFrames: { a: a.stats.jsJankFrames, b: b.stats.jsJankFrames, delta: b.stats.jsJankFrames - a.stats.jsJankFrames },
|
|
848
|
-
uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames }
|
|
1111
|
+
uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames },
|
|
1112
|
+
...compareRenders(a.renders, b.renders)
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
function compareRenders(a, b) {
|
|
1116
|
+
if (!a && !b) return null;
|
|
1117
|
+
const n = (v) => v ?? 0;
|
|
1118
|
+
return {
|
|
1119
|
+
renders: {
|
|
1120
|
+
total: { a: n(a?.totalRenders), b: n(b?.totalRenders), delta: n(b?.totalRenders) - n(a?.totalRenders) },
|
|
1121
|
+
commits: { a: n(a?.totalCommits), b: n(b?.totalCommits), delta: n(b?.totalCommits) - n(a?.totalCommits) },
|
|
1122
|
+
wasted: { a: n(a?.wastedRenders), b: n(b?.wastedRenders), delta: n(b?.wastedRenders) - n(a?.wastedRenders) },
|
|
1123
|
+
causes: { a: a?.causes ?? {}, b: b?.causes ?? {} }
|
|
1124
|
+
}
|
|
849
1125
|
};
|
|
850
1126
|
}
|
|
851
1127
|
function round(n, d) {
|
|
@@ -1095,7 +1371,7 @@ ${JSON.stringify(summary, null, 2)}
|
|
|
1095
1371
|
async ({ deviceId, maxWaitMs }) => {
|
|
1096
1372
|
const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
|
|
1097
1373
|
`);
|
|
1098
|
-
const
|
|
1374
|
+
const cap3 = maxWaitMs ?? 6e5;
|
|
1099
1375
|
try {
|
|
1100
1376
|
const target = sync.requireProDevice(deviceId);
|
|
1101
1377
|
const start = Date.now();
|
|
@@ -1103,7 +1379,7 @@ ${JSON.stringify(summary, null, 2)}
|
|
|
1103
1379
|
let idleStreak = 0;
|
|
1104
1380
|
let lastCount = -1;
|
|
1105
1381
|
let resolved = null;
|
|
1106
|
-
while (Date.now() - start <
|
|
1382
|
+
while (Date.now() - start < cap3) {
|
|
1107
1383
|
let snap2;
|
|
1108
1384
|
try {
|
|
1109
1385
|
snap2 = await readPerfSnapshot(sync, target);
|
|
@@ -1188,7 +1464,7 @@ ${JSON.stringify(ranking, null, 2)}
|
|
|
1188
1464
|
server.registerTool(
|
|
1189
1465
|
"compare_reports",
|
|
1190
1466
|
{
|
|
1191
|
-
description: "Compare two saved benchmark runs head-to-head
|
|
1467
|
+
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.",
|
|
1192
1468
|
inputSchema: {
|
|
1193
1469
|
reportIdA: import_zod2.z.string().describe("First (baseline) report id."),
|
|
1194
1470
|
reportIdB: import_zod2.z.string().describe("Second report id to compare against the baseline."),
|
|
@@ -1206,8 +1482,8 @@ ${JSON.stringify(ranking, null, 2)}
|
|
|
1206
1482
|
if (!b) return fail2(`Report "${reportIdB}" not found.`);
|
|
1207
1483
|
return json2(
|
|
1208
1484
|
compareReportStats(
|
|
1209
|
-
{ name: a.metadata.name, stats: a.stats },
|
|
1210
|
-
{ name: b.metadata.name, stats: b.stats }
|
|
1485
|
+
{ name: a.metadata.name, stats: a.stats, renders: a.renders },
|
|
1486
|
+
{ name: b.metadata.name, stats: b.stats, renders: b.renders }
|
|
1211
1487
|
)
|
|
1212
1488
|
);
|
|
1213
1489
|
} catch (e) {
|
|
@@ -2220,13 +2496,13 @@ function registerStorageTools(server, sync) {
|
|
|
2220
2496
|
const target = sync.requireProDevice(args.deviceId);
|
|
2221
2497
|
const pattern = args.pattern?.toLowerCase();
|
|
2222
2498
|
const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
|
|
2223
|
-
const
|
|
2499
|
+
const matches2 = (k) => !pattern || k.toLowerCase().includes(pattern);
|
|
2224
2500
|
const sections = [];
|
|
2225
2501
|
const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
|
|
2226
2502
|
if (!Array.isArray(allKeys)) {
|
|
2227
2503
|
return fail9("No storage result from the device. The storage tool may be inactive.");
|
|
2228
2504
|
}
|
|
2229
|
-
const asyncKeys = allKeys.filter(
|
|
2505
|
+
const asyncKeys = allKeys.filter(matches2).slice(0, limit);
|
|
2230
2506
|
if (asyncKeys.length > 0) {
|
|
2231
2507
|
let lines;
|
|
2232
2508
|
if (args.includeValues) {
|
|
@@ -2241,7 +2517,7 @@ ${lines.join("\n")}`);
|
|
|
2241
2517
|
}
|
|
2242
2518
|
const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
|
|
2243
2519
|
for (const inst of mmkv ?? []) {
|
|
2244
|
-
const entries = inst.entries.filter((e) =>
|
|
2520
|
+
const entries = inst.entries.filter((e) => matches2(e.key)).slice(0, limit);
|
|
2245
2521
|
if (entries.length === 0) continue;
|
|
2246
2522
|
const lines = entries.map(
|
|
2247
2523
|
(e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate2(e.value)}` : `- \`${e.key}\``
|
|
@@ -2251,7 +2527,7 @@ ${lines.join("\n")}`);
|
|
|
2251
2527
|
${lines.join("\n")}`);
|
|
2252
2528
|
}
|
|
2253
2529
|
const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
|
|
2254
|
-
const secureKeys = (secure ?? []).filter((d) =>
|
|
2530
|
+
const secureKeys = (secure ?? []).filter((d) => matches2(d.key)).slice(0, limit);
|
|
2255
2531
|
if (secureKeys.length > 0) {
|
|
2256
2532
|
const lines = await Promise.all(
|
|
2257
2533
|
secureKeys.map(async (d) => {
|
|
@@ -2419,7 +2695,10 @@ function registerConsoleTools(server, sync) {
|
|
|
2419
2695
|
async (args) => {
|
|
2420
2696
|
try {
|
|
2421
2697
|
const target = sync.requireProDevice(args.deviceId);
|
|
2422
|
-
const { payload } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
|
|
2698
|
+
const { payload, stale } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
|
|
2699
|
+
const staleHeader = stale ? `${stale}
|
|
2700
|
+
|
|
2701
|
+
` : "";
|
|
2423
2702
|
const p = Array.isArray(payload) ? { entries: payload } : payload ?? {};
|
|
2424
2703
|
const all = Array.isArray(p.entries) ? p.entries : [];
|
|
2425
2704
|
const minSev = args.level ? SEVERITY[args.level] : 0;
|
|
@@ -2431,12 +2710,12 @@ function registerConsoleTools(server, sync) {
|
|
|
2431
2710
|
const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 50;
|
|
2432
2711
|
const shown = sorted.slice(-limit);
|
|
2433
2712
|
if (shown.length === 0) {
|
|
2434
|
-
return text10(
|
|
2713
|
+
return text10(`${staleHeader}No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
|
|
2435
2714
|
}
|
|
2436
2715
|
const lines = shown.map((e) => `${hhmmss(e.timestamp)} [${TAG[e.level] ?? e.level}] ${e.message}`);
|
|
2437
2716
|
const levelNote = args.level ? `, level \u2265 ${args.level}` : "";
|
|
2438
2717
|
const header = `**Console \u2014 ${all.length} entries (showing ${shown.length}${levelNote}):**`;
|
|
2439
|
-
return text10(`${header}
|
|
2718
|
+
return text10(`${staleHeader}${header}
|
|
2440
2719
|
|
|
2441
2720
|
\`\`\`
|
|
2442
2721
|
${lines.join("\n")}
|
|
@@ -2460,13 +2739,19 @@ function kb(bytes) {
|
|
|
2460
2739
|
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
2461
2740
|
}
|
|
2462
2741
|
function formatOutcome(outcome) {
|
|
2463
|
-
const lines = Object.entries(outcome.results).map(([sid,
|
|
2464
|
-
const skips =
|
|
2742
|
+
const lines = Object.entries(outcome.results).map(([sid, r2]) => {
|
|
2743
|
+
const skips = r2.skipped.slice(0, 3).map((s) => `
|
|
2465
2744
|
\xB7 ${s.id}: ${s.reason}`).join("");
|
|
2466
|
-
const warns =
|
|
2745
|
+
const warns = r2.warnings.slice(0, 3).map((w) => `
|
|
2467
2746
|
\u26A0 ${w}`).join("");
|
|
2468
|
-
return ` ${
|
|
2747
|
+
return ` ${r2.ok ? "\u2713" : "\u2715"} ${sid} \u2014 applied ${r2.applied}${r2.skipped.length ? `, skipped ${r2.skipped.length}` : ""}${skips}${warns}`;
|
|
2469
2748
|
});
|
|
2749
|
+
const r = outcome.route;
|
|
2750
|
+
if (r) {
|
|
2751
|
+
lines.push(
|
|
2752
|
+
r.status === "navigated" ? ` \u2713 route \u2014 navigated to ${r.href}` : r.status === "deferred" ? ` \u21BB route \u2014 ${r.href} will be opened after the reload` : ` \u2715 route \u2014 ${r.error ?? r.status}`
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2470
2755
|
return `Restored "${outcome.snapshotName}" (${outcome.mode}${outcome.willReload ? ", app reloading" : ""}):
|
|
2471
2756
|
${lines.join("\n")}`;
|
|
2472
2757
|
}
|
|
@@ -2474,7 +2759,7 @@ function registerTimeMachineTools(server, sync) {
|
|
|
2474
2759
|
server.registerTool(
|
|
2475
2760
|
"get_time_machine",
|
|
2476
2761
|
{
|
|
2477
|
-
description: "List the app's Time Machine state snapshots (restore points) and which state sources (storage/redux/zustand/jotai/react-query) can currently be captured/restored. Use time_machine_action to capture a snapshot before driving a flow, then restore it to re-run the flow from the same state.",
|
|
2762
|
+
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.",
|
|
2478
2763
|
inputSchema: {
|
|
2479
2764
|
deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
2480
2765
|
}
|
|
@@ -2487,13 +2772,19 @@ function registerTimeMachineTools(server, sync) {
|
|
|
2487
2772
|
const snapshots = p.snapshots ?? [];
|
|
2488
2773
|
const providers = p.providers ?? [];
|
|
2489
2774
|
const providerLines = providers.map((pr) => {
|
|
2490
|
-
const
|
|
2491
|
-
return `- ${pr.label} (${pr.id}): ${
|
|
2775
|
+
const cap3 = pr.canCapture ? pr.canRestore ? "capture+restore" : "capture only" : "unavailable";
|
|
2776
|
+
return `- ${pr.label} (${pr.id}): ${cap3}${pr.detail ? ` \u2014 ${pr.detail}` : ""}`;
|
|
2492
2777
|
});
|
|
2778
|
+
if (p.route) {
|
|
2779
|
+
providerLines.push(
|
|
2780
|
+
p.route.available ? `- Route (route): capture+restore \u2014 recorded with every snapshot; pass restoreRoute:true to return to it${p.route.current ? ` (now ${p.route.current})` : ""}` : "- Route (route): unavailable \u2014 no Expo Router on this device, snapshots record no route"
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2493
2783
|
const snapLines = snapshots.map((s) => {
|
|
2494
2784
|
const warnings = Object.values(s.sources).reduce((n, src) => n + src.warnings.length, 0);
|
|
2495
2785
|
const desc = s.baseline ? "baseline \u2014 restore wipes to fresh install (always reloads)" : `${kb(s.sizeBytes)} \xB7 ${Object.keys(s.sources).join(", ")}`;
|
|
2496
|
-
|
|
2786
|
+
const route = s.route ? ` \xB7 route ${s.route.href}` : "";
|
|
2787
|
+
return `- \`${s.id}\` **${s.name}** \u2014 ${new Date(s.createdAt).toISOString()} \xB7 ${desc}${route}${warnings ? ` \xB7 \u26A0 ${warnings}` : ""}`;
|
|
2497
2788
|
});
|
|
2498
2789
|
const parts = [
|
|
2499
2790
|
`**Time Machine \u2014 ${snapshots.length} snapshot(s):**`,
|
|
@@ -2512,13 +2803,14 @@ ${formatOutcome(p.lastOutcome)}`);
|
|
|
2512
2803
|
server.registerTool(
|
|
2513
2804
|
"time_machine_action",
|
|
2514
2805
|
{
|
|
2515
|
-
description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
|
|
2806
|
+
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.",
|
|
2516
2807
|
inputSchema: {
|
|
2517
2808
|
action: import_zod12.z.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
|
|
2518
2809
|
id: import_zod12.z.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
|
|
2519
2810
|
name: import_zod12.z.string().optional().describe("Snapshot name (for capture and rename)."),
|
|
2520
2811
|
mode: import_zod12.z.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
|
|
2521
2812
|
excludeKeys: import_zod12.z.array(import_zod12.z.string()).optional().describe('Restore only: item keys to LEAVE UNTOUCHED, as "sourceId::itemKey" (itemKey = the key shown by the preview action, e.g. "query::[\\"pokemon\\"]" or "storage::async:@cart").'),
|
|
2813
|
+
restoreRoute: import_zod12.z.boolean().optional().describe("Restore only: also navigate back to the route the snapshot was captured on (shown as `route` by get_time_machine). With mode 'reload' the navigation happens automatically on the next boot. Default false \u2014 state is restored but the app stays on the current screen."),
|
|
2522
2814
|
sourceIds: import_zod12.z.array(import_zod12.z.string()).optional().describe("Capture/restore only these source ids (default all)."),
|
|
2523
2815
|
deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
2524
2816
|
}
|
|
@@ -2567,7 +2859,8 @@ ${lines.join("\n")}`);
|
|
|
2567
2859
|
const outcome = await call("restore", {
|
|
2568
2860
|
id: args.id,
|
|
2569
2861
|
mode: args.mode ?? "live",
|
|
2570
|
-
excludeKeys: args.excludeKeys
|
|
2862
|
+
excludeKeys: args.excludeKeys,
|
|
2863
|
+
restoreRoute: args.restoreRoute
|
|
2571
2864
|
});
|
|
2572
2865
|
return text11(formatOutcome(outcome));
|
|
2573
2866
|
}
|
|
@@ -2785,11 +3078,14 @@ function formatElement(e) {
|
|
|
2785
3078
|
if (e.nativeTag != null) line += ` #${e.nativeTag}`;
|
|
2786
3079
|
return line;
|
|
2787
3080
|
}
|
|
3081
|
+
var tapCount = 0;
|
|
3082
|
+
var FLOW_TIP_EVERY = 3;
|
|
3083
|
+
var FLOW_TIP = "\n\nTip: driving several steps? `run_flow` does a whole tap/type/wait/assert sequence in ONE call and reports the final screen plus the network and console output it produced.";
|
|
2788
3084
|
function registerInteractTools(server, sync) {
|
|
2789
3085
|
server.registerTool(
|
|
2790
3086
|
"describe_screen",
|
|
2791
3087
|
{
|
|
2792
|
-
description: "List the interactive / labeled elements currently on screen \u2014 the app's live accessibility-style tree walked from the React fiber tree. Each row shows the component name, visible text/label, role, testID, a normalized tap point (tap: x,y in 0\u20131), and its #nativeTag. Rows marked with \u2022 are tappable (have an onPress). Use this to see what's on screen and pick something to tap with tap_element \u2014 no screenshot needed. Re-run after navigating or when the screen changes, since positions move. Requires Buoy Pro.",
|
|
3088
|
+
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.",
|
|
2793
3089
|
inputSchema: {
|
|
2794
3090
|
interactiveOnly: import_zod14.z.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
|
|
2795
3091
|
deviceId: import_zod14.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
@@ -2818,7 +3114,7 @@ ${elements.map(formatElement).join("\n")}`);
|
|
|
2818
3114
|
server.registerTool(
|
|
2819
3115
|
"tap_element",
|
|
2820
3116
|
{
|
|
2821
|
-
description: "Interact with an on-screen element by invoking its handler directly in JS (no screenshot, no coordinate math, works on real devices). Handles buttons (onPress + onLongPress), toggles/switches and sliders (onValueChange), and text inputs (onChangeText) \u2014 see each element's `control` in describe_screen. Identify the target with an exact `nativeTag` or `testID`, or a fuzzy `query`. Pass `longPress:true` to invoke onLongPress instead of onPress (elements that support it show {long-press} in describe_screen). For a toggle, pass `value:true/false` (omit to flip); for a slider, pass a numeric `value` (omit to jump to the far end); for a text input, pass `text`. If the element is off-screen it's scrolled into view first (scrollIntoView:false to skip). Call describe_screen first to see options + current values. Requires Buoy Pro.",
|
|
3117
|
+
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.",
|
|
2822
3118
|
inputSchema: {
|
|
2823
3119
|
nativeTag: import_zod14.z.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
|
|
2824
3120
|
testID: import_zod14.z.string().optional().describe("Exact testID of the element."),
|
|
@@ -2850,13 +3146,15 @@ ${elements.map(formatElement).join("\n")}`);
|
|
|
2850
3146
|
},
|
|
2851
3147
|
target
|
|
2852
3148
|
);
|
|
3149
|
+
tapCount += 1;
|
|
3150
|
+
const flowTip = tapCount >= FLOW_TIP_EVERY && tapCount % FLOW_TIP_EVERY === 0 ? FLOW_TIP : "";
|
|
2853
3151
|
if (res?.tapped && res.matched) {
|
|
2854
3152
|
const m = res.matched;
|
|
2855
3153
|
const label = m.text ?? m.label ?? m.name;
|
|
2856
3154
|
const id = m.testID ? ` [testID=${m.testID}]` : m.nativeTag != null ? ` #${m.nativeTag}` : "";
|
|
2857
3155
|
const scrolledNote = res.scrolled ? " (scrolled into view)" : "";
|
|
2858
3156
|
const verb = m.via === "onValueChange" ? `Set "${label}"${id} \u2192 ${JSON.stringify(m.value)}` : m.via === "onChangeText" ? `Typed ${JSON.stringify(m.value)} into "${label}"${id}` : m.via === "onLongPress" ? `Long-pressed "${label}"${id}` : `Tapped "${label}"${id}`;
|
|
2859
|
-
return text12(`${verb}${scrolledNote}
|
|
3157
|
+
return text12(`${verb}${scrolledNote}.${flowTip}`);
|
|
2860
3158
|
}
|
|
2861
3159
|
let msg = res?.reason ?? "Tap failed.";
|
|
2862
3160
|
if (res?.candidates?.length) {
|
|
@@ -2944,7 +3242,7 @@ function registerJsThreadTools(server, sync) {
|
|
|
2944
3242
|
server.registerTool(
|
|
2945
3243
|
"get_js_thread_top",
|
|
2946
3244
|
{
|
|
2947
|
-
description:
|
|
3245
|
+
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.',
|
|
2948
3246
|
inputSchema: {
|
|
2949
3247
|
deviceId: import_zod15.z.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2950
3248
|
durationMs: import_zod15.z.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
|
|
@@ -3524,6 +3822,62 @@ ${JSON.stringify(slim)}
|
|
|
3524
3822
|
|
|
3525
3823
|
// src/tools/packs/reload.ts
|
|
3526
3824
|
var import_zod18 = require("zod");
|
|
3825
|
+
|
|
3826
|
+
// src/capture/devServerReload.ts
|
|
3827
|
+
var import_ws = require("ws");
|
|
3828
|
+
var CONNECT_TIMEOUT_MS = 3e3;
|
|
3829
|
+
function messageSocketUrl(origin) {
|
|
3830
|
+
return `${origin.replace(/^http/, "ws").replace(/\/+$/, "")}/message`;
|
|
3831
|
+
}
|
|
3832
|
+
function reloadViaDevServer(origin) {
|
|
3833
|
+
return new Promise((resolve) => {
|
|
3834
|
+
let settled = false;
|
|
3835
|
+
const done = (result) => {
|
|
3836
|
+
if (settled) return;
|
|
3837
|
+
settled = true;
|
|
3838
|
+
resolve(result);
|
|
3839
|
+
};
|
|
3840
|
+
let socket;
|
|
3841
|
+
try {
|
|
3842
|
+
socket = new import_ws.WebSocket(messageSocketUrl(origin));
|
|
3843
|
+
} catch (e) {
|
|
3844
|
+
done({ ok: false, error: e.message });
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
const timer = setTimeout(() => {
|
|
3848
|
+
try {
|
|
3849
|
+
socket.close();
|
|
3850
|
+
} catch {
|
|
3851
|
+
}
|
|
3852
|
+
done({ ok: false, error: `dev server at ${origin} did not accept a connection` });
|
|
3853
|
+
}, CONNECT_TIMEOUT_MS);
|
|
3854
|
+
let sent = false;
|
|
3855
|
+
socket.on("open", () => {
|
|
3856
|
+
try {
|
|
3857
|
+
socket.send(JSON.stringify({ version: 2, method: "reload" }));
|
|
3858
|
+
sent = true;
|
|
3859
|
+
clearTimeout(timer);
|
|
3860
|
+
setTimeout(() => {
|
|
3861
|
+
try {
|
|
3862
|
+
socket.close();
|
|
3863
|
+
} catch {
|
|
3864
|
+
}
|
|
3865
|
+
done({ ok: true });
|
|
3866
|
+
}, 250);
|
|
3867
|
+
} catch (e) {
|
|
3868
|
+
clearTimeout(timer);
|
|
3869
|
+
done({ ok: false, error: e.message });
|
|
3870
|
+
}
|
|
3871
|
+
});
|
|
3872
|
+
socket.on("error", (e) => {
|
|
3873
|
+
clearTimeout(timer);
|
|
3874
|
+
if (sent) return;
|
|
3875
|
+
done({ ok: false, error: e.message });
|
|
3876
|
+
});
|
|
3877
|
+
});
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
// src/tools/packs/reload.ts
|
|
3527
3881
|
var APP_TOOL_ID = "app";
|
|
3528
3882
|
var RELOAD_ACTION = "reloadApp";
|
|
3529
3883
|
var DEFAULT_WAIT_MS = 3e4;
|
|
@@ -3536,7 +3890,11 @@ var errMsg18 = (e) => e instanceof Error ? e.message : String(e);
|
|
|
3536
3890
|
var delay3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3537
3891
|
async function ping(sync, deviceId, timeoutMs) {
|
|
3538
3892
|
try {
|
|
3539
|
-
const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, {
|
|
3893
|
+
const pong = await sync.callAction(APP_TOOL_ID, "ping", {}, deviceId, {
|
|
3894
|
+
timeoutMs,
|
|
3895
|
+
silent: true
|
|
3896
|
+
});
|
|
3897
|
+
sync.noteDevServerOrigin(deviceId, pong?.devServerOrigin);
|
|
3540
3898
|
return typeof pong?.startedAt === "number" ? pong.startedAt : null;
|
|
3541
3899
|
} catch {
|
|
3542
3900
|
return null;
|
|
@@ -3565,7 +3923,7 @@ function registerReloadTools(server, sync) {
|
|
|
3565
3923
|
server.registerTool(
|
|
3566
3924
|
"reload_app",
|
|
3567
3925
|
{
|
|
3568
|
-
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,
|
|
3926
|
+
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.",
|
|
3569
3927
|
inputSchema: {
|
|
3570
3928
|
strategy: import_zod18.z.enum(["auto", "dev-settings", "expo-updates"]).optional().describe(
|
|
3571
3929
|
"Reload mechanism. 'auto' (default) uses DevSettings in dev builds and expo-updates otherwise. Force one only when debugging the reload itself."
|
|
@@ -3580,13 +3938,27 @@ function registerReloadTools(server, sync) {
|
|
|
3580
3938
|
const target = sync.requireProDevice(args.deviceId);
|
|
3581
3939
|
assertReloadSupported(sync, target);
|
|
3582
3940
|
const previousRealm = args.wait === false ? null : await ping(sync, target, PROBE_TIMEOUT_MS);
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3941
|
+
let ack = null;
|
|
3942
|
+
let recovered = false;
|
|
3943
|
+
try {
|
|
3944
|
+
ack = await sync.callAction(
|
|
3945
|
+
APP_TOOL_ID,
|
|
3946
|
+
RELOAD_ACTION,
|
|
3947
|
+
{ strategy: args.strategy ?? "auto" },
|
|
3948
|
+
target
|
|
3949
|
+
);
|
|
3950
|
+
} catch (inAppError) {
|
|
3951
|
+
const origin = sync.devServerOrigin(target);
|
|
3952
|
+
if (!origin) throw inAppError;
|
|
3953
|
+
const res = await reloadViaDevServer(origin);
|
|
3954
|
+
if (!res.ok) {
|
|
3955
|
+
throw new Error(
|
|
3956
|
+
`The app is not answering (it looks crashed) and the dev server at ${origin} could not be reached either: ${res.error}. Reload the app by hand.`
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
recovered = true;
|
|
3960
|
+
}
|
|
3961
|
+
const via = recovered ? ` via the dev server (the app was unresponsive \u2014 its own reload could not be reached)` : ack?.strategy ? ` via ${ack.strategy}` : "";
|
|
3590
3962
|
if (args.wait === false) {
|
|
3591
3963
|
return text16(`Reload requested${via}. The app is restarting \u2014 its in-memory state is gone; re-read anything you cached.`);
|
|
3592
3964
|
}
|
|
@@ -3606,12 +3978,836 @@ function registerReloadTools(server, sync) {
|
|
|
3606
3978
|
);
|
|
3607
3979
|
}
|
|
3608
3980
|
|
|
3981
|
+
// src/tools/packs/triage.ts
|
|
3982
|
+
var import_zod19 = require("zod");
|
|
3983
|
+
var text17 = (s) => ({ content: [{ type: "text", text: s }] });
|
|
3984
|
+
var fail19 = (message) => ({
|
|
3985
|
+
isError: true,
|
|
3986
|
+
content: [{ type: "text", text: message }]
|
|
3987
|
+
});
|
|
3988
|
+
var errMsg19 = (e) => e instanceof Error ? e.message : String(e);
|
|
3989
|
+
var PROBE_TIMEOUT_MS2 = 5e3;
|
|
3990
|
+
var MAX_STORAGE_KEYS = 20;
|
|
3991
|
+
var MAX_STORAGE_VALUE = 400;
|
|
3992
|
+
var STORAGE_VALUE_BUDGET = 1600;
|
|
3993
|
+
var MAX_LINE = 160;
|
|
3994
|
+
var cap = (s, n = MAX_LINE) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
3995
|
+
function hhmmss2(ts) {
|
|
3996
|
+
if (!ts) return "--:--:--";
|
|
3997
|
+
const d = new Date(ts);
|
|
3998
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
3999
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
4000
|
+
}
|
|
4001
|
+
async function otherBackends(sync, target, opts) {
|
|
4002
|
+
const notes = [];
|
|
4003
|
+
try {
|
|
4004
|
+
const mmkv = await sync.callAction("storage", "mmkv.snapshot", {}, target, opts);
|
|
4005
|
+
for (const inst of mmkv ?? []) {
|
|
4006
|
+
const n = inst.entries?.length ?? 0;
|
|
4007
|
+
if (n > 0) notes.push(`MMKV "${inst.id}": ${n} key${n === 1 ? "" : "s"}`);
|
|
4008
|
+
}
|
|
4009
|
+
} catch {
|
|
4010
|
+
}
|
|
4011
|
+
try {
|
|
4012
|
+
const secure = await sync.callAction("storage", "secure.keys", {}, target, opts);
|
|
4013
|
+
if (secure?.length) notes.push(`SecureStore: ${secure.length} key${secure.length === 1 ? "" : "s"}`);
|
|
4014
|
+
} catch {
|
|
4015
|
+
}
|
|
4016
|
+
return notes.length ? `
|
|
4017
|
+
${notes.join(" \xB7 ")} \u2014 read with get_storage` : "";
|
|
4018
|
+
}
|
|
4019
|
+
async function section(title, run) {
|
|
4020
|
+
try {
|
|
4021
|
+
const body = await run();
|
|
4022
|
+
return body ? `${title}
|
|
4023
|
+
${body}` : null;
|
|
4024
|
+
} catch (e) {
|
|
4025
|
+
const msg = errMsg19(e);
|
|
4026
|
+
if (/not responding|timed out|got no answer/i.test(msg)) {
|
|
4027
|
+
return `${title}
|
|
4028
|
+
(no answer \u2014 see the note above)`;
|
|
4029
|
+
}
|
|
4030
|
+
return `${title}
|
|
4031
|
+
(unavailable: ${cap(msg, 120)})`;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
function registerTriageTools(server, sync) {
|
|
4035
|
+
server.registerTool(
|
|
4036
|
+
"get_triage",
|
|
4037
|
+
{
|
|
4038
|
+
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.",
|
|
4039
|
+
inputSchema: {
|
|
4040
|
+
deviceId: import_zod19.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
4041
|
+
}
|
|
4042
|
+
},
|
|
4043
|
+
async (args) => {
|
|
4044
|
+
try {
|
|
4045
|
+
const target = sync.requireProDevice(args.deviceId);
|
|
4046
|
+
const device = sync.connectedDevices().find((d) => d.deviceId === target);
|
|
4047
|
+
const toolIds = new Set((device?.tools ?? []).map((t) => t.toolId));
|
|
4048
|
+
const head = `**${device?.deviceName ?? target}** (${device?.platform ?? "?"}, id \`${target}\`) \u2014 tools: ${[...toolIds].join(", ") || "none"}`;
|
|
4049
|
+
const opts = { timeoutMs: PROBE_TIMEOUT_MS2 };
|
|
4050
|
+
const [consoleSec, netSec, storeSec, screenSec] = await Promise.all([
|
|
4051
|
+
section("**Console \u2014 recent errors/warnings:**", async () => {
|
|
4052
|
+
if (!toolIds.has("console")) return null;
|
|
4053
|
+
const { payload } = await sync.getSnapshot("console", target, opts);
|
|
4054
|
+
const p = payload;
|
|
4055
|
+
const entries = Array.isArray(p) ? p : p?.entries ?? [];
|
|
4056
|
+
const bad = entries.filter(
|
|
4057
|
+
(e) => e.level === "error" || e.level === "warning"
|
|
4058
|
+
);
|
|
4059
|
+
const shown = bad.slice(-8);
|
|
4060
|
+
if (shown.length === 0) {
|
|
4061
|
+
return `(none \u2014 ${entries.length} entries captured)`;
|
|
4062
|
+
}
|
|
4063
|
+
return shown.map(
|
|
4064
|
+
(e) => `${hhmmss2(e.timestamp)} [${e.level === "error" ? "ERR" : "warn"}] ${cap(
|
|
4065
|
+
(e.message ?? "").split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 3).join(" \u23CE "),
|
|
4066
|
+
220
|
|
4067
|
+
)}`
|
|
4068
|
+
).join("\n");
|
|
4069
|
+
}),
|
|
4070
|
+
section("**Network \u2014 recent requests:**", async () => {
|
|
4071
|
+
if (!toolIds.has("network")) return null;
|
|
4072
|
+
const { payload } = await sync.getSnapshot("network", target, opts);
|
|
4073
|
+
const events = Array.isArray(payload) ? payload : payload?.events ?? [];
|
|
4074
|
+
if (events.length === 0) return "(no requests captured yet)";
|
|
4075
|
+
const shown = events.slice(-8);
|
|
4076
|
+
return shown.map((e) => {
|
|
4077
|
+
const mark = e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705";
|
|
4078
|
+
const where = e.path ?? e.url ?? "?";
|
|
4079
|
+
const status = e.error ?? e.status ?? "pending";
|
|
4080
|
+
return `${mark} ${e.method ?? "GET"} ${cap(String(where), 80)} \u2192 ${status}${e.duration ? ` (${Math.round(e.duration)}ms)` : ""}`;
|
|
4081
|
+
}).join("\n");
|
|
4082
|
+
}),
|
|
4083
|
+
section("**Storage \u2014 persisted values:**", async () => {
|
|
4084
|
+
if (!toolIds.has("storage")) return null;
|
|
4085
|
+
const keys = await sync.callAction(
|
|
4086
|
+
"storage",
|
|
4087
|
+
"async.getAllKeys",
|
|
4088
|
+
{},
|
|
4089
|
+
target,
|
|
4090
|
+
opts
|
|
4091
|
+
);
|
|
4092
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
4093
|
+
return "(no app keys)";
|
|
4094
|
+
}
|
|
4095
|
+
const head2 = keys.slice(0, MAX_STORAGE_KEYS);
|
|
4096
|
+
let pairs = [];
|
|
4097
|
+
try {
|
|
4098
|
+
pairs = await sync.callAction(
|
|
4099
|
+
"storage",
|
|
4100
|
+
"async.multiGet",
|
|
4101
|
+
{ keys: head2 },
|
|
4102
|
+
target,
|
|
4103
|
+
opts
|
|
4104
|
+
) ?? [];
|
|
4105
|
+
} catch {
|
|
4106
|
+
return `${head2.map((k) => `\`${k}\``).join(", ")}
|
|
4107
|
+
_Values: get_storage includeValues:true_`;
|
|
4108
|
+
}
|
|
4109
|
+
const byKey = new Map(pairs);
|
|
4110
|
+
let budget = STORAGE_VALUE_BUDGET;
|
|
4111
|
+
const lines = head2.map((k) => {
|
|
4112
|
+
const v = byKey.get(k);
|
|
4113
|
+
if (v == null) return `- \`${k}\` = (null)`;
|
|
4114
|
+
if (v.length > MAX_STORAGE_VALUE) {
|
|
4115
|
+
return `- \`${k}\` = (${(v.length / 1024).toFixed(1)} KB \u2014 read with get_storage)`;
|
|
4116
|
+
}
|
|
4117
|
+
if (budget <= 0) return `- \`${k}\` = (not shown \u2014 triage budget spent; read with get_storage)`;
|
|
4118
|
+
budget -= v.length;
|
|
4119
|
+
return `- \`${k}\` = ${v}`;
|
|
4120
|
+
});
|
|
4121
|
+
const more = keys.length - head2.length;
|
|
4122
|
+
const others = await otherBackends(sync, target, opts);
|
|
4123
|
+
return `${lines.join("\n")}${more > 0 ? `
|
|
4124
|
+
\u2026+${more} more keys` : ""}${others}`;
|
|
4125
|
+
}),
|
|
4126
|
+
section("**Screen \u2014 what's visible now:**", async () => {
|
|
4127
|
+
if (!toolIds.has("highlight-updates")) return null;
|
|
4128
|
+
const res = await sync.callAction(
|
|
4129
|
+
"highlight-updates",
|
|
4130
|
+
"describeScreen",
|
|
4131
|
+
{},
|
|
4132
|
+
target,
|
|
4133
|
+
opts
|
|
4134
|
+
);
|
|
4135
|
+
const els = (res?.elements ?? []).filter((e) => e.interactive);
|
|
4136
|
+
if (els.length === 0) return "(nothing interactive on screen)";
|
|
4137
|
+
return els.slice(0, 20).map(
|
|
4138
|
+
(e) => `\u2022 ${e.name}${e.text || e.label ? ` "${cap(e.text ?? e.label ?? "", 60)}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`
|
|
4139
|
+
).join("\n");
|
|
4140
|
+
})
|
|
4141
|
+
]);
|
|
4142
|
+
const crashed = sync.isUnresponsive(target);
|
|
4143
|
+
const banner = crashed ? `
|
|
4144
|
+
|
|
4145
|
+
\u{1F6D1} ${sync.unresponsiveNote(target)}` : "";
|
|
4146
|
+
const body = [consoleSec, netSec, storeSec, screenSec].filter(Boolean).join("\n\n");
|
|
4147
|
+
return text17(
|
|
4148
|
+
`${head}${banner}
|
|
4149
|
+
|
|
4150
|
+
${body}
|
|
4151
|
+
|
|
4152
|
+
_Drill in with get_console, get_network_requests (includeBodies:true for response bodies), get_storage (includeValues:true), describe_screen._`
|
|
4153
|
+
);
|
|
4154
|
+
} catch (e) {
|
|
4155
|
+
return fail19(errMsg19(e));
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
// src/tools/packs/flow.ts
|
|
4162
|
+
var import_zod20 = require("zod");
|
|
4163
|
+
var UI_TOOL_ID = "highlight-updates";
|
|
4164
|
+
var APP_TOOL_ID2 = "app";
|
|
4165
|
+
var text18 = (s) => ({ content: [{ type: "text", text: s }] });
|
|
4166
|
+
var fail20 = (message) => ({
|
|
4167
|
+
isError: true,
|
|
4168
|
+
content: [{ type: "text", text: message }]
|
|
4169
|
+
});
|
|
4170
|
+
var errMsg20 = (e) => e instanceof Error ? e.message : String(e);
|
|
4171
|
+
var delay4 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4172
|
+
var POLL_MS = 250;
|
|
4173
|
+
var DEFAULT_WAIT_MS2 = 5e3;
|
|
4174
|
+
var ASSERT_SETTLE_MS = 2e3;
|
|
4175
|
+
var TEARDOWN_GRACE_MS2 = 1200;
|
|
4176
|
+
var REALM_PROBE_MS = 1500;
|
|
4177
|
+
var MAX_FLOW_MS = 12e4;
|
|
4178
|
+
function matches(e, needle) {
|
|
4179
|
+
if (e.testID && e.testID === needle) return true;
|
|
4180
|
+
const n = needle.toLowerCase();
|
|
4181
|
+
return [e.testID, e.text, e.label, e.name].filter(Boolean).some((v) => String(v).toLowerCase().includes(n));
|
|
4182
|
+
}
|
|
4183
|
+
function formatElement2(e) {
|
|
4184
|
+
const display = e.text ?? e.label;
|
|
4185
|
+
return `${e.interactive ? "\u2022 " : " "}${e.name}${display ? ` "${display}"` : ""}${e.testID ? ` [testID=${e.testID}]` : ""}`;
|
|
4186
|
+
}
|
|
4187
|
+
var stepSchema = import_zod20.z.object({
|
|
4188
|
+
tap: import_zod20.z.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
|
|
4189
|
+
longPress: import_zod20.z.boolean().optional().describe("Make `tap` a long-press instead."),
|
|
4190
|
+
type: import_zod20.z.string().optional().describe("Text to type (needs `into`)."),
|
|
4191
|
+
into: import_zod20.z.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
|
|
4192
|
+
set: import_zod20.z.union([import_zod20.z.number(), import_zod20.z.boolean()]).optional().describe("Set a toggle (true/false) or slider (number) \u2014 pair with `tap` as the target."),
|
|
4193
|
+
reload: import_zod20.z.boolean().optional().describe("Reload the JS bundle before this step's action."),
|
|
4194
|
+
waitMs: import_zod20.z.number().min(0).max(3e4).optional().describe("Fixed pause after the action, in ms (max 30000)."),
|
|
4195
|
+
waitFor: import_zod20.z.string().optional().describe("Wait until an element matching this appears (default 5s)."),
|
|
4196
|
+
waitGone: import_zod20.z.string().optional().describe("Wait until no element matches this (default 5s)."),
|
|
4197
|
+
expect: import_zod20.z.string().optional().describe("Assert an element matching this IS on screen (waits up to 2s for it); fails the flow if not."),
|
|
4198
|
+
expectGone: import_zod20.z.string().optional().describe("Assert nothing matches this (waits up to 2s for it to go); fails the flow if something does."),
|
|
4199
|
+
timeoutMs: import_zod20.z.number().min(0).max(6e4).optional().describe("Budget for this step's waitFor/waitGone/expect, max 60000. Default 5000 (2000 for expect).")
|
|
4200
|
+
}).describe("One flow step. Order within a step: reload \u2192 tap/type/set \u2192 waitMs \u2192 waitFor/waitGone \u2192 expect.");
|
|
4201
|
+
function registerFlowTools(server, sync) {
|
|
4202
|
+
server.registerTool(
|
|
4203
|
+
"run_flow",
|
|
4204
|
+
{
|
|
4205
|
+
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.",
|
|
4206
|
+
inputSchema: {
|
|
4207
|
+
steps: import_zod20.z.array(stepSchema).min(1).describe("Steps to run, in order."),
|
|
4208
|
+
include: import_zod20.z.enum(["all", "screen", "none"]).optional().describe("What to report after the flow: 'all' (default) = final screen + network/console produced by the flow; 'screen' = final screen only; 'none' = step results only."),
|
|
4209
|
+
continueOnFailure: import_zod20.z.boolean().optional().describe("Keep going after a failed step instead of stopping. Default false."),
|
|
4210
|
+
deviceId: import_zod20.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
4211
|
+
}
|
|
4212
|
+
},
|
|
4213
|
+
async (args) => {
|
|
4214
|
+
const started = Date.now();
|
|
4215
|
+
try {
|
|
4216
|
+
const target = sync.requireProDevice(args.deviceId);
|
|
4217
|
+
const include = args.include ?? "all";
|
|
4218
|
+
const describe = async () => {
|
|
4219
|
+
const res = await sync.callAction(UI_TOOL_ID, "describeScreen", {}, target);
|
|
4220
|
+
return res?.elements ?? [];
|
|
4221
|
+
};
|
|
4222
|
+
const realmId = async () => {
|
|
4223
|
+
try {
|
|
4224
|
+
const pong = await sync.callAction(APP_TOOL_ID2, "ping", {}, target, {
|
|
4225
|
+
timeoutMs: REALM_PROBE_MS,
|
|
4226
|
+
silent: true
|
|
4227
|
+
// probes across a reload are expected to fail
|
|
4228
|
+
});
|
|
4229
|
+
return typeof pong?.startedAt === "number" ? pong.startedAt : null;
|
|
4230
|
+
} catch {
|
|
4231
|
+
return null;
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
4234
|
+
const untilNewRealm = async (previous, budgetMs) => {
|
|
4235
|
+
const t0 = Date.now();
|
|
4236
|
+
while (Date.now() - t0 < budgetMs) {
|
|
4237
|
+
const realm = await realmId();
|
|
4238
|
+
if (realm !== null && realm !== previous) return Date.now() - t0;
|
|
4239
|
+
await delay4(POLL_MS);
|
|
4240
|
+
}
|
|
4241
|
+
return null;
|
|
4242
|
+
};
|
|
4243
|
+
const beforeConsole = /* @__PURE__ */ new Set();
|
|
4244
|
+
const beforeNetwork = /* @__PURE__ */ new Set();
|
|
4245
|
+
const toolIds = new Set(
|
|
4246
|
+
(sync.connectedDevices().find((d) => d.deviceId === target)?.tools ?? []).map((t) => t.toolId)
|
|
4247
|
+
);
|
|
4248
|
+
if (include === "all") {
|
|
4249
|
+
if (toolIds.has("console")) {
|
|
4250
|
+
const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
|
|
4251
|
+
const entries = payload?.entries ?? [];
|
|
4252
|
+
for (const e of entries) beforeConsole.add(e.id);
|
|
4253
|
+
}
|
|
4254
|
+
if (toolIds.has("network")) {
|
|
4255
|
+
const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
|
|
4256
|
+
const events = Array.isArray(payload) ? payload : payload?.events ?? [];
|
|
4257
|
+
for (const e of events) beforeNetwork.add(e.id);
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
const until = async (predicate, budgetMs) => {
|
|
4261
|
+
const t0 = Date.now();
|
|
4262
|
+
for (; ; ) {
|
|
4263
|
+
if (Date.now() - t0 >= budgetMs || Date.now() - started > MAX_FLOW_MS) {
|
|
4264
|
+
return { ok: false, ms: Date.now() - t0 };
|
|
4265
|
+
}
|
|
4266
|
+
let els = [];
|
|
4267
|
+
try {
|
|
4268
|
+
els = await describe();
|
|
4269
|
+
} catch {
|
|
4270
|
+
}
|
|
4271
|
+
if (predicate(els)) return { ok: true, ms: Date.now() - t0 };
|
|
4272
|
+
await delay4(POLL_MS);
|
|
4273
|
+
}
|
|
4274
|
+
};
|
|
4275
|
+
const lines = [];
|
|
4276
|
+
let failedAt = null;
|
|
4277
|
+
let okCount = 0;
|
|
4278
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
4279
|
+
const step = args.steps[i];
|
|
4280
|
+
const label = `${i + 1}`;
|
|
4281
|
+
const budget = step.timeoutMs ?? DEFAULT_WAIT_MS2;
|
|
4282
|
+
if (Date.now() - started > MAX_FLOW_MS) {
|
|
4283
|
+
lines.push(`${label} \u2717 flow exceeded ${MAX_FLOW_MS / 1e3}s \u2014 stopped here`);
|
|
4284
|
+
failedAt = i;
|
|
4285
|
+
break;
|
|
4286
|
+
}
|
|
4287
|
+
const stepErrors = [];
|
|
4288
|
+
const done = [];
|
|
4289
|
+
const actionable = step.reload || step.tap || step.type !== void 0 || step.waitMs || step.waitFor || step.waitGone || step.expect || step.expectGone;
|
|
4290
|
+
if (!actionable) {
|
|
4291
|
+
lines.push(`${i + 1} \u2717 step does nothing \u2014 give it a tap/type/wait/expect${step.set !== void 0 ? " (`set` needs `tap` naming the control)" : ""}`);
|
|
4292
|
+
failedAt = failedAt ?? i;
|
|
4293
|
+
if (!args.continueOnFailure) break;
|
|
4294
|
+
continue;
|
|
4295
|
+
}
|
|
4296
|
+
try {
|
|
4297
|
+
if (step.reload) {
|
|
4298
|
+
const previousRealm = await realmId();
|
|
4299
|
+
await sync.callAction(APP_TOOL_ID2, "reloadApp", { strategy: "auto" }, target);
|
|
4300
|
+
await delay4(TEARDOWN_GRACE_MS2);
|
|
4301
|
+
const back = await untilNewRealm(previousRealm, Math.max(budget, 2e4));
|
|
4302
|
+
if (back == null) {
|
|
4303
|
+
stepErrors.push("app did not come back after reload");
|
|
4304
|
+
} else {
|
|
4305
|
+
done.push(`reloaded (${back}ms)`);
|
|
4306
|
+
await until((els) => els.length > 0, 1e4);
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
if (step.type !== void 0) {
|
|
4310
|
+
const targetSel = step.into ?? step.tap;
|
|
4311
|
+
if (!targetSel) {
|
|
4312
|
+
stepErrors.push("`type` needs `into` (which input to type into)");
|
|
4313
|
+
} else {
|
|
4314
|
+
const res = await sync.callAction(
|
|
4315
|
+
UI_TOOL_ID,
|
|
4316
|
+
"tapElement",
|
|
4317
|
+
{ ...selector(targetSel), text: step.type },
|
|
4318
|
+
target
|
|
4319
|
+
);
|
|
4320
|
+
if (!res?.tapped) {
|
|
4321
|
+
stepErrors.push(res?.reason ?? `could not type into "${targetSel}"`);
|
|
4322
|
+
} else if (res.matched?.via && res.matched.via !== "onChangeText") {
|
|
4323
|
+
stepErrors.push(
|
|
4324
|
+
`"${targetSel}" is not a text input \u2014 it ran ${res.matched.via} instead, and the text was not entered`
|
|
4325
|
+
);
|
|
4326
|
+
} else {
|
|
4327
|
+
done.push(`typed ${JSON.stringify(step.type)} into "${targetSel}"`);
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
} else if (step.tap) {
|
|
4331
|
+
const params = { ...selector(step.tap) };
|
|
4332
|
+
if (step.longPress) params.longPress = true;
|
|
4333
|
+
if (step.set !== void 0) params.value = step.set;
|
|
4334
|
+
const res = await sync.callAction(UI_TOOL_ID, "tapElement", params, target);
|
|
4335
|
+
if (!res?.tapped) {
|
|
4336
|
+
stepErrors.push(res?.reason ?? `no element matched "${step.tap}"`);
|
|
4337
|
+
} else {
|
|
4338
|
+
const m = res.matched;
|
|
4339
|
+
const what = m?.text ?? m?.label ?? m?.name ?? step.tap;
|
|
4340
|
+
if (step.set !== void 0 && m?.via && m.via !== "onValueChange") {
|
|
4341
|
+
stepErrors.push(
|
|
4342
|
+
`"${what}" is not a toggle/slider \u2014 it ran ${m.via} instead, so it was not set to ${JSON.stringify(step.set)}`
|
|
4343
|
+
);
|
|
4344
|
+
} else {
|
|
4345
|
+
done.push(
|
|
4346
|
+
step.set !== void 0 ? `set "${what}" \u2192 ${JSON.stringify(m?.value ?? step.set)}` : `${step.longPress ? "long-pressed" : "tapped"} "${what}"`
|
|
4347
|
+
);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
if (!stepErrors.length && step.waitMs) {
|
|
4352
|
+
await delay4(step.waitMs);
|
|
4353
|
+
done.push(`waited ${step.waitMs}ms`);
|
|
4354
|
+
}
|
|
4355
|
+
if (!stepErrors.length && step.waitFor) {
|
|
4356
|
+
const r = await until((els) => els.some((e) => matches(e, step.waitFor)), budget);
|
|
4357
|
+
if (r.ok) done.push(`"${step.waitFor}" appeared (${r.ms}ms)`);
|
|
4358
|
+
else stepErrors.push(`"${step.waitFor}" never appeared within ${budget}ms`);
|
|
4359
|
+
}
|
|
4360
|
+
if (!stepErrors.length && step.waitGone) {
|
|
4361
|
+
const r = await until((els) => !els.some((e) => matches(e, step.waitGone)), budget);
|
|
4362
|
+
if (r.ok) done.push(`"${step.waitGone}" gone (${r.ms}ms)`);
|
|
4363
|
+
else stepErrors.push(`"${step.waitGone}" was still there after ${budget}ms`);
|
|
4364
|
+
}
|
|
4365
|
+
if (!stepErrors.length && step.expect) {
|
|
4366
|
+
const r = await until(
|
|
4367
|
+
(els) => els.some((e) => matches(e, step.expect)),
|
|
4368
|
+
step.timeoutMs ?? ASSERT_SETTLE_MS
|
|
4369
|
+
);
|
|
4370
|
+
if (r.ok) done.push(`"${step.expect}" is on screen`);
|
|
4371
|
+
else stepErrors.push(`expected "${step.expect}" on screen \u2014 not found`);
|
|
4372
|
+
}
|
|
4373
|
+
if (!stepErrors.length && step.expectGone) {
|
|
4374
|
+
const r = await until(
|
|
4375
|
+
(els) => !els.some((e) => matches(e, step.expectGone)),
|
|
4376
|
+
step.timeoutMs ?? ASSERT_SETTLE_MS
|
|
4377
|
+
);
|
|
4378
|
+
if (r.ok) done.push(`"${step.expectGone}" is gone`);
|
|
4379
|
+
else stepErrors.push(`expected "${step.expectGone}" to be gone \u2014 still on screen`);
|
|
4380
|
+
}
|
|
4381
|
+
} catch (e) {
|
|
4382
|
+
stepErrors.push(errMsg20(e));
|
|
4383
|
+
}
|
|
4384
|
+
if (stepErrors.length) {
|
|
4385
|
+
lines.push(`${label} \u2717 ${describeStep(step)} \u2014 ${stepErrors.join("; ")}`);
|
|
4386
|
+
failedAt = failedAt ?? i;
|
|
4387
|
+
if (!args.continueOnFailure) break;
|
|
4388
|
+
} else {
|
|
4389
|
+
okCount++;
|
|
4390
|
+
lines.push(`${label} \u2713 ${done.join(", ") || describeStep(step)}`);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
const elapsed = ((Date.now() - started) / 1e3).toFixed(1);
|
|
4394
|
+
const header = failedAt === null ? `**Flow: ${okCount}/${args.steps.length} steps OK (${elapsed}s)**` : `**Flow FAILED at step ${failedAt + 1} \u2014 ${okCount}/${args.steps.length} steps OK (${elapsed}s)**`;
|
|
4395
|
+
const parts = [header, lines.join("\n")];
|
|
4396
|
+
if (include !== "none") {
|
|
4397
|
+
try {
|
|
4398
|
+
const els = await describe();
|
|
4399
|
+
const shown = els.filter((e) => e.interactive).slice(0, 30);
|
|
4400
|
+
parts.push(
|
|
4401
|
+
`**Screen now (${shown.length} interactive of ${els.length}):**
|
|
4402
|
+
${shown.map(formatElement2).join("\n")}`
|
|
4403
|
+
);
|
|
4404
|
+
} catch (e) {
|
|
4405
|
+
parts.push(`**Screen now:** unavailable \u2014 ${cap2(errMsg20(e))}`);
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
if (include === "all") {
|
|
4409
|
+
if (toolIds.has("network")) {
|
|
4410
|
+
const { payload } = await sync.getSnapshot("network", target).catch(() => ({ payload: null }));
|
|
4411
|
+
const events = Array.isArray(payload) ? payload : payload?.events ?? [];
|
|
4412
|
+
const fresh = events.filter((e) => !beforeNetwork.has(e.id)).slice(-15);
|
|
4413
|
+
if (fresh.length) {
|
|
4414
|
+
parts.push(
|
|
4415
|
+
`**Network during this flow (${fresh.length}):**
|
|
4416
|
+
` + fresh.map(
|
|
4417
|
+
(e) => `${e.error || (e.status ?? 0) >= 400 ? "\u274C" : "\u2705"} ${e.method ?? "GET"} ${e.path ?? e.url ?? "?"} \u2192 ${e.error ?? e.status ?? "pending"}`
|
|
4418
|
+
).join("\n")
|
|
4419
|
+
);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
if (toolIds.has("console")) {
|
|
4423
|
+
const { payload } = await sync.getSnapshot("console", target).catch(() => ({ payload: null }));
|
|
4424
|
+
const entries = payload?.entries ?? [];
|
|
4425
|
+
const fresh = entries.filter((e) => !beforeConsole.has(e.id) && (e.level === "error" || e.level === "warning")).slice(-10);
|
|
4426
|
+
if (fresh.length) {
|
|
4427
|
+
parts.push(
|
|
4428
|
+
`**Console during this flow (${fresh.length}):**
|
|
4429
|
+
` + fresh.map((e) => `[${e.level === "error" ? "ERR" : "warn"}] ${cap2(firstLines(e.message ?? ""), 220)}`).join("\n")
|
|
4430
|
+
);
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
return text18(parts.join("\n\n"));
|
|
4435
|
+
} catch (e) {
|
|
4436
|
+
return fail20(errMsg20(e));
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
);
|
|
4440
|
+
}
|
|
4441
|
+
function selector(needle) {
|
|
4442
|
+
return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
|
|
4443
|
+
}
|
|
4444
|
+
function describeStep(step) {
|
|
4445
|
+
if (step.tap) return `tap "${step.tap}"`;
|
|
4446
|
+
if (step.type !== void 0) return `type into "${step.into ?? "?"}"`;
|
|
4447
|
+
if (step.reload) return "reload";
|
|
4448
|
+
if (step.waitFor) return `waitFor "${step.waitFor}"`;
|
|
4449
|
+
if (step.waitGone) return `waitGone "${step.waitGone}"`;
|
|
4450
|
+
if (step.expect) return `expect "${step.expect}"`;
|
|
4451
|
+
return "step";
|
|
4452
|
+
}
|
|
4453
|
+
var cap2 = (s, n = 160) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
4454
|
+
var firstLines = (s, n = 3) => s.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, n).join(" \u23CE ");
|
|
4455
|
+
|
|
4456
|
+
// src/tools/packs/renders.ts
|
|
4457
|
+
var import_zod21 = require("zod");
|
|
4458
|
+
var UI_TOOL_ID2 = "highlight-updates";
|
|
4459
|
+
var text19 = (s) => ({ content: [{ type: "text", text: s }] });
|
|
4460
|
+
var fail21 = (message) => ({
|
|
4461
|
+
isError: true,
|
|
4462
|
+
content: [{ type: "text", text: message }]
|
|
4463
|
+
});
|
|
4464
|
+
var errMsg21 = (e) => e instanceof Error ? e.message : String(e);
|
|
4465
|
+
var delay5 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
4466
|
+
var DEFAULT_IDLE_MS = 3e3;
|
|
4467
|
+
var SETTLE_MS = 400;
|
|
4468
|
+
var DEFAULT_TOP = 12;
|
|
4469
|
+
var lastCapture = /* @__PURE__ */ new Map();
|
|
4470
|
+
var stepSchema2 = import_zod21.z.object({
|
|
4471
|
+
tap: import_zod21.z.string().optional().describe("Tap this element: exact testID, or fuzzy visible text."),
|
|
4472
|
+
type: import_zod21.z.string().optional().describe("Text to type (needs `into`)."),
|
|
4473
|
+
into: import_zod21.z.string().optional().describe("Target text input for `type`: testID or fuzzy text."),
|
|
4474
|
+
waitMs: import_zod21.z.number().min(0).max(1e4).optional().describe("Pause after the action, in ms."),
|
|
4475
|
+
repeat: import_zod21.z.number().min(1).max(20).optional().describe("Do this step N times (default 1). Useful for 'tap it five times'.")
|
|
4476
|
+
}).describe("One interaction to measure. Keep it to the thing you're asking about.");
|
|
4477
|
+
function selector2(needle) {
|
|
4478
|
+
return /\s/.test(needle) ? { query: needle } : { testID: needle, query: needle };
|
|
4479
|
+
}
|
|
4480
|
+
function dominant(causes) {
|
|
4481
|
+
if (!causes) return "unknown";
|
|
4482
|
+
let best = "unknown";
|
|
4483
|
+
let n = -1;
|
|
4484
|
+
for (const [k, v] of Object.entries(causes)) {
|
|
4485
|
+
if ((v ?? 0) > n) {
|
|
4486
|
+
n = v ?? 0;
|
|
4487
|
+
best = k;
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
return best;
|
|
4491
|
+
}
|
|
4492
|
+
var CAUSE_HINT = {
|
|
4493
|
+
propsUnstable: "props changed by IDENTITY only (a fresh function/object from the parent) \u2014 fix upstream, not in the child",
|
|
4494
|
+
parent: "parent re-rendered and nothing of its own changed \u2014 a pure cascade",
|
|
4495
|
+
props: "props genuinely changed value",
|
|
4496
|
+
hooks: "its own state/hooks changed",
|
|
4497
|
+
mount: "first render"
|
|
4498
|
+
};
|
|
4499
|
+
function formatCapture(c, top) {
|
|
4500
|
+
const s = c.summary;
|
|
4501
|
+
const wasted = s.wastedRenders ?? 0;
|
|
4502
|
+
const pct = s.totalRenders > 0 ? Math.round(wasted / s.totalRenders * 100) : 0;
|
|
4503
|
+
const lines = [];
|
|
4504
|
+
const ms = s.totalRenderMs ?? 0;
|
|
4505
|
+
lines.push(
|
|
4506
|
+
`**${s.totalRenders} component renders across ${s.totalCommits} commits in ${(c.windowMs / 1e3).toFixed(1)}s**` + (ms > 0 ? ` \xB7 ${ms.toFixed(1)}ms of render work` : "") + ` \xB7 ${wasted} wasted (${pct}%)`
|
|
4507
|
+
);
|
|
4508
|
+
const causeLine = Object.entries(s.causes ?? {}).filter(([, v]) => (v ?? 0) > 0).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).map(([k, v]) => `${k} ${v}`).join(" \xB7 ");
|
|
4509
|
+
if (causeLine) lines.push(`causes: ${causeLine}`);
|
|
4510
|
+
lines.push("");
|
|
4511
|
+
lines.push("| component | self ms | renders | cause | dragged along by | changed props |");
|
|
4512
|
+
lines.push("|---|---:|---:|---|---|---|");
|
|
4513
|
+
const rows = [...s.topComponents].sort((a, b) => (b.totalMs ?? 0) - (a.totalMs ?? 0) || b.renders - a.renders).slice(0, top);
|
|
4514
|
+
for (const r of rows) {
|
|
4515
|
+
lines.push(
|
|
4516
|
+
`| ${r.name} | ${(r.totalMs ?? 0).toFixed(1)} | ${r.renders} | ${dominant(r.causes)} | ${r.parentName ?? "\u2014"} | ${(r.changedProps ?? []).join(", ") || "\u2014"} |`
|
|
4517
|
+
);
|
|
4518
|
+
}
|
|
4519
|
+
if (s.topComponents.length > top) {
|
|
4520
|
+
lines.push(`_\u2026+${s.topComponents.length - top} more components_`);
|
|
4521
|
+
}
|
|
4522
|
+
const seen = new Set(rows.map((r) => dominant(r.causes)));
|
|
4523
|
+
const hints = [...seen].filter((k) => CAUSE_HINT[k]).map((k) => `\`${k}\` = ${CAUSE_HINT[k]}`);
|
|
4524
|
+
if (hints.length) {
|
|
4525
|
+
lines.push("");
|
|
4526
|
+
lines.push(hints.join(" \xB7 "));
|
|
4527
|
+
}
|
|
4528
|
+
lines.push("");
|
|
4529
|
+
lines.push(
|
|
4530
|
+
"`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."
|
|
4531
|
+
);
|
|
4532
|
+
return lines.join("\n");
|
|
4533
|
+
}
|
|
4534
|
+
function formatDelta(before, after) {
|
|
4535
|
+
const d = (a, b) => {
|
|
4536
|
+
const diff = b - a;
|
|
4537
|
+
return `${a} \u2192 ${b} (${diff > 0 ? "+" : ""}${diff})`;
|
|
4538
|
+
};
|
|
4539
|
+
const bs = before.summary;
|
|
4540
|
+
const as_ = after.summary;
|
|
4541
|
+
const lines = [`**vs "${before.label}"**`];
|
|
4542
|
+
lines.push(`- total renders: ${d(bs.totalRenders, as_.totalRenders)}`);
|
|
4543
|
+
lines.push(`- wasted: ${d(bs.wastedRenders ?? 0, as_.wastedRenders ?? 0)}`);
|
|
4544
|
+
const beforeByName = new Map(bs.topComponents.map((r) => [r.name, r.renders]));
|
|
4545
|
+
const moved = [];
|
|
4546
|
+
for (const r of as_.topComponents.slice(0, 20)) {
|
|
4547
|
+
const was = beforeByName.get(r.name) ?? 0;
|
|
4548
|
+
if (was !== r.renders) moved.push(`${r.name} ${d(was, r.renders)}`);
|
|
4549
|
+
beforeByName.delete(r.name);
|
|
4550
|
+
}
|
|
4551
|
+
for (const [name, was] of beforeByName) {
|
|
4552
|
+
if (was > 0) moved.push(`${name} ${d(was, 0)}`);
|
|
4553
|
+
}
|
|
4554
|
+
if (moved.length) lines.push(`- per component: ${moved.slice(0, 10).join(" \xB7 ")}`);
|
|
4555
|
+
const fewer = as_.totalRenders < bs.totalRenders;
|
|
4556
|
+
const more = as_.totalRenders > bs.totalRenders;
|
|
4557
|
+
lines.push(
|
|
4558
|
+
more ? "\n\u26A0\uFE0F MORE renders than before \u2014 this change made this interaction worse. Re-render fixes routinely move cost instead of removing it; check the other interactions too before moving on." : fewer ? "\n\u2705 Fewer renders than before on this interaction. Measure the OTHER interactions too \u2014 a fix that helps typing can cost taps." : "\n\u2796 No change on this interaction."
|
|
4559
|
+
);
|
|
4560
|
+
return lines.join("\n");
|
|
4561
|
+
}
|
|
4562
|
+
function registerRenderTools(server, sync) {
|
|
4563
|
+
server.registerTool(
|
|
4564
|
+
"measure_renders",
|
|
4565
|
+
{
|
|
4566
|
+
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.",
|
|
4567
|
+
inputSchema: {
|
|
4568
|
+
steps: import_zod21.z.array(stepSchema2).max(20).optional().describe(
|
|
4569
|
+
"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."
|
|
4570
|
+
),
|
|
4571
|
+
idleMs: import_zod21.z.number().min(500).max(2e4).optional().describe(`Idle sample length when no steps are given. Default ${DEFAULT_IDLE_MS}.`),
|
|
4572
|
+
label: import_zod21.z.string().optional().describe('Name this measurement, e.g. "before fix". Shown in the next comparison.'),
|
|
4573
|
+
compareToPrevious: import_zod21.z.boolean().optional().describe("Diff against the previous measure_renders on this device. Use after an edit to prove the fix."),
|
|
4574
|
+
top: import_zod21.z.number().min(1).max(40).optional().describe(`Components to list. Default ${DEFAULT_TOP}.`),
|
|
4575
|
+
deviceId: import_zod21.z.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
4576
|
+
}
|
|
4577
|
+
},
|
|
4578
|
+
async (args) => {
|
|
4579
|
+
try {
|
|
4580
|
+
const target = sync.requireProDevice(args.deviceId);
|
|
4581
|
+
const device = sync.connectedDevices().find((d) => d.deviceId === target);
|
|
4582
|
+
const uiTool = (device?.tools ?? []).find((t) => t.toolId === UI_TOOL_ID2);
|
|
4583
|
+
if (!uiTool) {
|
|
4584
|
+
return fail21(
|
|
4585
|
+
"This app doesn't expose the highlight-updates tool, so renders can't be measured. Install @buoy-gg/highlight-updates."
|
|
4586
|
+
);
|
|
4587
|
+
}
|
|
4588
|
+
if (!(uiTool.actions ?? []).includes("beginMeasurement")) {
|
|
4589
|
+
return fail21(
|
|
4590
|
+
"This app's @buoy-gg/highlight-updates is too old to measure renders (needs the version with beginMeasurement). Upgrade it, then try again. Meanwhile run_benchmark_batch with captureRenders can still give counts if you have a test route."
|
|
4591
|
+
);
|
|
4592
|
+
}
|
|
4593
|
+
const began = await sync.callAction(UI_TOOL_ID2, "beginMeasurement", {}, target);
|
|
4594
|
+
if (began && began.ok === false) {
|
|
4595
|
+
return fail21(`Can't measure renders: ${began.reason ?? "capture unavailable"}`);
|
|
4596
|
+
}
|
|
4597
|
+
await delay5(SETTLE_MS);
|
|
4598
|
+
const startedAt = Date.now();
|
|
4599
|
+
const notes = [];
|
|
4600
|
+
if (args.steps?.length) {
|
|
4601
|
+
for (const step of args.steps) {
|
|
4602
|
+
const times = step.repeat ?? 1;
|
|
4603
|
+
for (let i = 0; i < times; i++) {
|
|
4604
|
+
if (step.type !== void 0) {
|
|
4605
|
+
const sel = step.into ?? step.tap;
|
|
4606
|
+
if (!sel) {
|
|
4607
|
+
notes.push("a `type` step needs `into`");
|
|
4608
|
+
continue;
|
|
4609
|
+
}
|
|
4610
|
+
const res = await sync.callAction(
|
|
4611
|
+
UI_TOOL_ID2,
|
|
4612
|
+
"tapElement",
|
|
4613
|
+
{ ...selector2(sel), text: step.type },
|
|
4614
|
+
target
|
|
4615
|
+
);
|
|
4616
|
+
if (!res?.tapped) notes.push(res?.reason ?? `could not type into "${sel}"`);
|
|
4617
|
+
else if (res.matched?.via && res.matched.via !== "onChangeText") {
|
|
4618
|
+
notes.push(`"${sel}" is not a text input \u2014 it ran ${res.matched.via}, nothing was typed`);
|
|
4619
|
+
}
|
|
4620
|
+
} else if (step.tap) {
|
|
4621
|
+
const res = await sync.callAction(
|
|
4622
|
+
UI_TOOL_ID2,
|
|
4623
|
+
"tapElement",
|
|
4624
|
+
selector2(step.tap),
|
|
4625
|
+
target
|
|
4626
|
+
);
|
|
4627
|
+
if (!res?.tapped) notes.push(res?.reason ?? `no element matched "${step.tap}"`);
|
|
4628
|
+
}
|
|
4629
|
+
if (step.waitMs) await delay5(step.waitMs);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
await delay5(SETTLE_MS);
|
|
4633
|
+
} else {
|
|
4634
|
+
await delay5(args.idleMs ?? DEFAULT_IDLE_MS);
|
|
4635
|
+
}
|
|
4636
|
+
const windowMs = Date.now() - startedAt;
|
|
4637
|
+
const ended = await sync.callAction(UI_TOOL_ID2, "endMeasurement", {}, target);
|
|
4638
|
+
const summary = ended?.summary;
|
|
4639
|
+
if (!summary) {
|
|
4640
|
+
return fail21(
|
|
4641
|
+
"The capture window closed without returning anything. If the app reloaded mid-measurement, try again."
|
|
4642
|
+
);
|
|
4643
|
+
}
|
|
4644
|
+
const capture = {
|
|
4645
|
+
label: args.label ?? (args.steps?.length ? "interaction" : "idle"),
|
|
4646
|
+
windowMs,
|
|
4647
|
+
summary
|
|
4648
|
+
};
|
|
4649
|
+
const prev = lastCapture.get(target);
|
|
4650
|
+
lastCapture.set(target, capture);
|
|
4651
|
+
const what = args.steps?.length ? args.steps.map((s) => (s.type !== void 0 ? `type "${s.type}"` : `tap "${s.tap}"`) + (s.repeat ? ` \xD7${s.repeat}` : "")).join(", ") : `idle ${(windowMs / 1e3).toFixed(1)}s`;
|
|
4652
|
+
const parts = [`**measure_renders \u2014 ${what}**`, "", formatCapture(capture, args.top ?? DEFAULT_TOP)];
|
|
4653
|
+
if (args.compareToPrevious) {
|
|
4654
|
+
parts.push("", prev ? formatDelta(prev, capture) : "_No previous measurement on this device to compare against._");
|
|
4655
|
+
}
|
|
4656
|
+
if (notes.length) parts.push("", `\u26A0\uFE0F ${notes.join("; ")}`);
|
|
4657
|
+
if (summary.totalRenders === 0) {
|
|
4658
|
+
parts.push(
|
|
4659
|
+
"",
|
|
4660
|
+
"_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._"
|
|
4661
|
+
);
|
|
4662
|
+
} else if (!args.compareToPrevious) {
|
|
4663
|
+
parts.push("", "_Fixed something? Call this again with the same steps and `compareToPrevious:true` to prove it landed._");
|
|
4664
|
+
}
|
|
4665
|
+
return text19(parts.join("\n"));
|
|
4666
|
+
} catch (e) {
|
|
4667
|
+
return fail21(errMsg21(e));
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
);
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
// src/verify/editWatcher.ts
|
|
4674
|
+
var import_node_fs3 = __toESM(require("fs"));
|
|
4675
|
+
var import_node_path3 = __toESM(require("path"));
|
|
4676
|
+
var WATCHED_EXT = /* @__PURE__ */ new Set([
|
|
4677
|
+
".ts",
|
|
4678
|
+
".tsx",
|
|
4679
|
+
".js",
|
|
4680
|
+
".jsx",
|
|
4681
|
+
".mjs",
|
|
4682
|
+
".cjs",
|
|
4683
|
+
".json"
|
|
4684
|
+
]);
|
|
4685
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
4686
|
+
"node_modules",
|
|
4687
|
+
".git",
|
|
4688
|
+
".expo",
|
|
4689
|
+
"ios",
|
|
4690
|
+
"android",
|
|
4691
|
+
"dist",
|
|
4692
|
+
"lib",
|
|
4693
|
+
"build",
|
|
4694
|
+
".next",
|
|
4695
|
+
"coverage",
|
|
4696
|
+
"__snapshots__"
|
|
4697
|
+
]);
|
|
4698
|
+
var DEBOUNCE_MS = 400;
|
|
4699
|
+
function resolveMode(raw) {
|
|
4700
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
4701
|
+
if (v === "always" || v === "never" || v === "auto") return v;
|
|
4702
|
+
return "auto";
|
|
4703
|
+
}
|
|
4704
|
+
var EditWatcher = class {
|
|
4705
|
+
constructor() {
|
|
4706
|
+
this.watcher = null;
|
|
4707
|
+
this.changed = /* @__PURE__ */ new Set();
|
|
4708
|
+
this.lastEditAt = 0;
|
|
4709
|
+
this.nudgedForCurrentBatch = false;
|
|
4710
|
+
this.mode = "auto";
|
|
4711
|
+
this.started = false;
|
|
4712
|
+
}
|
|
4713
|
+
start(rootDir, mode) {
|
|
4714
|
+
this.mode = mode;
|
|
4715
|
+
if (mode === "never" || this.started) return;
|
|
4716
|
+
this.started = true;
|
|
4717
|
+
try {
|
|
4718
|
+
this.watcher = import_node_fs3.default.watch(
|
|
4719
|
+
rootDir,
|
|
4720
|
+
{ recursive: true },
|
|
4721
|
+
(_event, filename) => {
|
|
4722
|
+
if (!filename) return;
|
|
4723
|
+
const rel2 = String(filename);
|
|
4724
|
+
if (rel2.split(import_node_path3.default.sep).some((seg) => IGNORED_DIRS.has(seg))) return;
|
|
4725
|
+
if (!WATCHED_EXT.has(import_node_path3.default.extname(rel2))) return;
|
|
4726
|
+
this.noteEdit(rel2);
|
|
4727
|
+
}
|
|
4728
|
+
);
|
|
4729
|
+
this.watcher.on("error", () => this.stop());
|
|
4730
|
+
} catch {
|
|
4731
|
+
this.stop();
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
/** Record an edit. Public so the nudge policy can be tested without fs.watch. */
|
|
4735
|
+
noteEdit(rel2) {
|
|
4736
|
+
const now = Date.now();
|
|
4737
|
+
if (now - this.lastEditAt > DEBOUNCE_MS) this.nudgedForCurrentBatch = false;
|
|
4738
|
+
this.lastEditAt = now;
|
|
4739
|
+
if (this.changed.size < 50) this.changed.add(rel2);
|
|
4740
|
+
}
|
|
4741
|
+
stop() {
|
|
4742
|
+
try {
|
|
4743
|
+
this.watcher?.close();
|
|
4744
|
+
} catch {
|
|
4745
|
+
}
|
|
4746
|
+
this.watcher = null;
|
|
4747
|
+
}
|
|
4748
|
+
/** Something on the device was actually checked — the edits are accounted for. */
|
|
4749
|
+
markVerified() {
|
|
4750
|
+
this.changed.clear();
|
|
4751
|
+
this.nudgedForCurrentBatch = false;
|
|
4752
|
+
}
|
|
4753
|
+
hasUnverifiedEdits() {
|
|
4754
|
+
return this.changed.size > 0;
|
|
4755
|
+
}
|
|
4756
|
+
/**
|
|
4757
|
+
* The line to append to a tool result, or null to stay quiet.
|
|
4758
|
+
*
|
|
4759
|
+
* `auto` says it once per burst of edits, so it lands when the agent is
|
|
4760
|
+
* mid-loop and then gets out of the way. `always` repeats while anything is
|
|
4761
|
+
* unverified, for people who want the belt and braces.
|
|
4762
|
+
*/
|
|
4763
|
+
nudge() {
|
|
4764
|
+
if (this.mode === "never" || !this.hasUnverifiedEdits()) return null;
|
|
4765
|
+
if (this.mode === "auto") {
|
|
4766
|
+
if (this.nudgedForCurrentBatch) return null;
|
|
4767
|
+
this.nudgedForCurrentBatch = true;
|
|
4768
|
+
}
|
|
4769
|
+
const files = [...this.changed];
|
|
4770
|
+
const shown = files.slice(0, 3).join(", ");
|
|
4771
|
+
const more = files.length > 3 ? ` +${files.length - 3} more` : "";
|
|
4772
|
+
return `
|
|
4773
|
+
|
|
4774
|
+
---
|
|
4775
|
+
\u26A0\uFE0F **${files.length} source file${files.length === 1 ? "" : "s"} changed since anything was last checked on the device** (${shown}${more}).
|
|
4776
|
+
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.`;
|
|
4777
|
+
}
|
|
4778
|
+
};
|
|
4779
|
+
var editWatcher = new EditWatcher();
|
|
4780
|
+
|
|
3609
4781
|
// src/server.ts
|
|
3610
4782
|
var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
|
|
3611
4783
|
|
|
3612
4784
|
Buoy tools live inside the app and sync to a local broker; this server connects to that
|
|
3613
|
-
broker as a dashboard client.
|
|
3614
|
-
|
|
4785
|
+
broker as a dashboard client.
|
|
4786
|
+
|
|
4787
|
+
DEBUGGING A RUNNING APP? Call \`get_triage\` FIRST \u2014 one call returns the connected device,
|
|
4788
|
+
recent console errors (including [FATAL] crashes), recent network requests with statuses,
|
|
4789
|
+
persisted storage keys, and what's on screen. It replaces the usual five-call opening
|
|
4790
|
+
sequence and tells you which tool to drill into. Read runtime facts before hypothesizing
|
|
4791
|
+
from source: the app's actual state beats a guess about what the code should do.
|
|
4792
|
+
|
|
4793
|
+
Routing after triage \u2014 match the SYMPTOM to the tool:
|
|
4794
|
+
- Value wrong/stale/NaN, or broken only after an upgrade \u2192 \`get_storage includeValues:true\`
|
|
4795
|
+
(a bad persisted value is a top root cause, and it survives reinstalls of the JS).
|
|
4796
|
+
- Failed request, wrong data from the API, auth/session bug \u2192 \`get_network_requests
|
|
4797
|
+
includeBodies:true\` and read the response BODY.
|
|
4798
|
+
- Crash or redbox \u2192 \`get_console\` (fatals are captured, and still readable after the app
|
|
4799
|
+
dies), then \`get_storage\` for the state that triggered it.
|
|
4800
|
+
- Data on screen doesn't match the API \u2192 \`get_react_query\` / \`get_zustand_state\` /
|
|
4801
|
+
\`get_redux_state\` / \`get_jotai_state\` for the cache or store between them.
|
|
4802
|
+
|
|
4803
|
+
AFTER YOU EDIT, PROVE IT on the device \u2014 the most common way a fix fails is being incomplete,
|
|
4804
|
+
not being wrong. Reload (\`run_flow\` step \`{reload:true}\`, or \`reload_app\`) and re-drive EVERY
|
|
4805
|
+
screen the symptom could appear on, asserting the corrected value: a price shown in three places
|
|
4806
|
+
has to be right in all three, and a migration only counts if it survives a restart. A fix you
|
|
4807
|
+
haven't watched work is a guess.
|
|
4808
|
+
|
|
4809
|
+
Use \`list_devices\` when you need the full tool/action inventory or several devices are
|
|
4810
|
+
connected (\`get_triage\` already names the active one).
|
|
3615
4811
|
|
|
3616
4812
|
To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
|
|
3617
4813
|
react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
|
|
@@ -3630,15 +4826,28 @@ Need to SEE a component (verify a UI change, check styling/layout)? Use \`screen
|
|
|
3630
4826
|
a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
|
|
3631
4827
|
iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
|
|
3632
4828
|
|
|
3633
|
-
Need to DRIVE the UI
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
4829
|
+
Need to DRIVE the UI? Reach for \`run_flow\` FIRST: it runs a whole scripted sequence
|
|
4830
|
+
(tap / type / set / reload / waitFor / expect) in ONE call and reports the per-step result, the
|
|
4831
|
+
final screen, and the network + console output the flow produced. Reproducing a bug and verifying
|
|
4832
|
+
a fix are one call each instead of a dozen alternating reads and taps, and a failed step reports
|
|
4833
|
+
the screen at the point it broke. Drop to \`describe_screen\` (list on-screen elements with testIDs
|
|
4834
|
+
and tap points) + \`tap_element\` only for one-off pokes or when you don't yet know what's there.
|
|
4835
|
+
|
|
4836
|
+
Screen slow / laggy / janky / stutters, typing lags, or "the whole page re-renders when I tap
|
|
4837
|
+
one thing"? Start with \`measure_renders\` \u2014 one call, no test route needed. Pass \`steps\` for the
|
|
4838
|
+
interaction you're actually asking about (a tap, some typing); an idle sample says nothing about
|
|
4839
|
+
what a tap costs. It returns per-component render counts, WHY each rendered (parent cascade / own
|
|
4840
|
+
state / changed props), which parent dragged it along, and \`wasted\` \u2014 the renders that produced
|
|
4841
|
+
nothing new. After you edit, call it again with the same steps and \`compareToPrevious:true\`:
|
|
4842
|
+
re-render fixes routinely move the cost somewhere else instead of removing it, and only a second
|
|
4843
|
+
measurement catches that.
|
|
3637
4844
|
|
|
3638
4845
|
JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
|
|
3639
4846
|
\`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
|
|
3640
4847
|
freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
|
|
3641
4848
|
Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
|
|
4849
|
+
This is about TASKS on the thread \u2014 for React components re-rendering too often, it's
|
|
4850
|
+
\`measure_renders\`, not this.
|
|
3642
4851
|
|
|
3643
4852
|
Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
|
|
3644
4853
|
expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
|
|
@@ -3674,11 +4883,46 @@ If no device is connected, open the app with Buoy devtools running and try again
|
|
|
3674
4883
|
The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
|
|
3675
4884
|
require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
|
|
3676
4885
|
required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
|
|
4886
|
+
var VERIFYING_TOOLS = /* @__PURE__ */ new Set([
|
|
4887
|
+
"measure_renders",
|
|
4888
|
+
"run_flow",
|
|
4889
|
+
"run_benchmark_batch",
|
|
4890
|
+
"get_triage",
|
|
4891
|
+
"tap_element",
|
|
4892
|
+
"describe_screen",
|
|
4893
|
+
"get_console",
|
|
4894
|
+
"get_network_requests",
|
|
4895
|
+
"get_events",
|
|
4896
|
+
"get_snapshot"
|
|
4897
|
+
]);
|
|
3677
4898
|
function createMcpServer(sync) {
|
|
3678
4899
|
const server = new import_mcp.McpServer(
|
|
3679
4900
|
{ name: "buoy", version: VERSION },
|
|
3680
4901
|
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
|
|
3681
4902
|
);
|
|
4903
|
+
editWatcher.start(process.cwd(), resolveMode(process.env.BUOY_VERIFY));
|
|
4904
|
+
const originalRegister = server.registerTool.bind(server);
|
|
4905
|
+
server.registerTool = ((name, config, handler) => originalRegister(name, config, (async (...args) => {
|
|
4906
|
+
const result = await handler(...args);
|
|
4907
|
+
const errored = result?.isError === true;
|
|
4908
|
+
if (VERIFYING_TOOLS.has(name) && !errored) {
|
|
4909
|
+
editWatcher.markVerified();
|
|
4910
|
+
return result;
|
|
4911
|
+
}
|
|
4912
|
+
const note = editWatcher.nudge();
|
|
4913
|
+
if (!note) return result;
|
|
4914
|
+
const r = result;
|
|
4915
|
+
if (Array.isArray(r?.content)) {
|
|
4916
|
+
return {
|
|
4917
|
+
...r,
|
|
4918
|
+
content: [...r.content, { type: "text", text: note }]
|
|
4919
|
+
};
|
|
4920
|
+
}
|
|
4921
|
+
return result;
|
|
4922
|
+
})));
|
|
4923
|
+
registerTriageTools(server, sync);
|
|
4924
|
+
registerFlowTools(server, sync);
|
|
4925
|
+
registerRenderTools(server, sync);
|
|
3682
4926
|
registerGenericTools(server, sync);
|
|
3683
4927
|
registerPerfMonitorTools(server, sync);
|
|
3684
4928
|
registerEventsTools(server, sync);
|
|
@@ -3700,34 +4944,37 @@ function createMcpServer(sync) {
|
|
|
3700
4944
|
return server;
|
|
3701
4945
|
}
|
|
3702
4946
|
var deviceIdField = {
|
|
3703
|
-
deviceId:
|
|
4947
|
+
deviceId: import_zod22.z.string().optional().describe(
|
|
3704
4948
|
'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
|
|
3705
4949
|
)
|
|
3706
4950
|
};
|
|
3707
4951
|
|
|
3708
4952
|
// src/init.ts
|
|
3709
4953
|
var import_node_child_process2 = require("child_process");
|
|
3710
|
-
var
|
|
4954
|
+
var import_node_fs4 = __toESM(require("fs"));
|
|
3711
4955
|
var import_node_os3 = __toESM(require("os"));
|
|
3712
|
-
var
|
|
4956
|
+
var import_node_path4 = __toESM(require("path"));
|
|
4957
|
+
function defaultEnv() {
|
|
4958
|
+
return { BUOY_VERIFY: "auto" };
|
|
4959
|
+
}
|
|
3713
4960
|
var PUBLIC_REGISTRY = "https://registry.npmjs.org";
|
|
3714
|
-
var
|
|
4961
|
+
var PROBE_TIMEOUT_MS3 = 3e3;
|
|
3715
4962
|
function runInit(opts = {}) {
|
|
3716
4963
|
const cwd = opts.cwd ?? process.cwd();
|
|
3717
|
-
const skillSrcDir = opts.skillSrcDir ??
|
|
4964
|
+
const skillSrcDir = opts.skillSrcDir ?? import_node_path4.default.resolve(__dirname, "../skill");
|
|
3718
4965
|
const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
|
|
3719
4966
|
const entry = resolveEntry(opts, log);
|
|
3720
4967
|
const done = [];
|
|
3721
|
-
upsertMcpConfig(
|
|
3722
|
-
if (
|
|
3723
|
-
upsertMcpConfig(
|
|
4968
|
+
upsertMcpConfig(import_node_path4.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
|
|
4969
|
+
if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".cursor"))) {
|
|
4970
|
+
upsertMcpConfig(import_node_path4.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
|
|
3724
4971
|
}
|
|
3725
|
-
if (
|
|
3726
|
-
upsertMcpConfig(
|
|
4972
|
+
if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".vscode"))) {
|
|
4973
|
+
upsertMcpConfig(import_node_path4.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
|
|
3727
4974
|
}
|
|
3728
4975
|
installSkills(skillSrcDir, cwd, done);
|
|
3729
|
-
appendDebuggingGuide(
|
|
3730
|
-
appendDebuggingGuide(
|
|
4976
|
+
appendDebuggingGuide(import_node_path4.default.join(cwd, "CLAUDE.md"), cwd, done);
|
|
4977
|
+
appendDebuggingGuide(import_node_path4.default.join(cwd, "AGENTS.md"), cwd, done);
|
|
3731
4978
|
log("Buoy MCP installed:");
|
|
3732
4979
|
for (const line of done) log(" \u2022 " + line);
|
|
3733
4980
|
log("");
|
|
@@ -3747,7 +4994,7 @@ function runInit(opts = {}) {
|
|
|
3747
4994
|
}
|
|
3748
4995
|
function resolveEntry(opts, log) {
|
|
3749
4996
|
if (opts.cliPath) {
|
|
3750
|
-
return { type: "stdio", command: "node", args: [opts.cliPath] };
|
|
4997
|
+
return { type: "stdio", command: "node", args: [opts.cliPath], env: defaultEnv() };
|
|
3751
4998
|
}
|
|
3752
4999
|
const mode = opts.mode ?? "auto";
|
|
3753
5000
|
if (mode === "npx") {
|
|
@@ -3766,21 +5013,21 @@ function resolveEntry(opts, log) {
|
|
|
3766
5013
|
return localEntry(opts, log);
|
|
3767
5014
|
}
|
|
3768
5015
|
function npxEntry() {
|
|
3769
|
-
return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
|
|
5016
|
+
return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`], env: defaultEnv() };
|
|
3770
5017
|
}
|
|
3771
5018
|
function localEntry(opts, log) {
|
|
3772
|
-
const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ??
|
|
5019
|
+
const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? import_node_path4.default.join(import_node_os3.default.homedir(), ".buoy-mcp");
|
|
3773
5020
|
const registry = opts.registry ?? PUBLIC_REGISTRY;
|
|
3774
5021
|
const cliPath = installLocalCopy(installDir, registry, log);
|
|
3775
5022
|
if (cliPath) {
|
|
3776
|
-
return { type: "stdio", command: "node", args: [cliPath] };
|
|
5023
|
+
return { type: "stdio", command: "node", args: [cliPath], env: defaultEnv() };
|
|
3777
5024
|
}
|
|
3778
5025
|
log("\u26A0 local install failed \u2014 writing the npx entry instead (see the error above).");
|
|
3779
5026
|
return npxEntry();
|
|
3780
5027
|
}
|
|
3781
5028
|
function installLocalCopy(installDir, registry, log) {
|
|
3782
5029
|
const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
|
|
3783
|
-
|
|
5030
|
+
import_node_fs4.default.mkdirSync(installDir, { recursive: true });
|
|
3784
5031
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
3785
5032
|
log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
|
|
3786
5033
|
const res = (0, import_node_child_process2.spawnSync)(
|
|
@@ -3803,8 +5050,8 @@ function installLocalCopy(installDir, registry, log) {
|
|
|
3803
5050
|
log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
|
|
3804
5051
|
return null;
|
|
3805
5052
|
}
|
|
3806
|
-
const cliPath =
|
|
3807
|
-
if (!
|
|
5053
|
+
const cliPath = import_node_path4.default.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
|
|
5054
|
+
if (!import_node_fs4.default.existsSync(cliPath)) {
|
|
3808
5055
|
log(`\u26A0 install finished but ${cliPath} is missing.`);
|
|
3809
5056
|
return null;
|
|
3810
5057
|
}
|
|
@@ -3826,7 +5073,7 @@ function effectiveRegistry() {
|
|
|
3826
5073
|
}
|
|
3827
5074
|
function npmConfigGet(key) {
|
|
3828
5075
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
3829
|
-
const res = (0, import_node_child_process2.spawnSync)(npm, ["config", "get", key], { encoding: "utf8", timeout:
|
|
5076
|
+
const res = (0, import_node_child_process2.spawnSync)(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 });
|
|
3830
5077
|
if (res.status !== 0 || typeof res.stdout !== "string") return null;
|
|
3831
5078
|
const val = res.stdout.trim();
|
|
3832
5079
|
if (!val || val === "undefined" || val === "null") return null;
|
|
@@ -3835,8 +5082,8 @@ function npmConfigGet(key) {
|
|
|
3835
5082
|
function hostReachable(registry) {
|
|
3836
5083
|
const done = (0, import_node_child_process2.spawnSync)(
|
|
3837
5084
|
process.execPath,
|
|
3838
|
-
["-e", REACHABILITY_PROBE, registry, String(
|
|
3839
|
-
{ encoding: "utf8", timeout:
|
|
5085
|
+
["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS3)],
|
|
5086
|
+
{ encoding: "utf8", timeout: PROBE_TIMEOUT_MS3 + 2e3 }
|
|
3840
5087
|
);
|
|
3841
5088
|
return done.status === 0;
|
|
3842
5089
|
}
|
|
@@ -3856,22 +5103,22 @@ fetch(url, { method: 'HEAD', signal: c.signal })
|
|
|
3856
5103
|
`;
|
|
3857
5104
|
function upsertMcpConfig(file, key, entry, cwd, done) {
|
|
3858
5105
|
let json3 = {};
|
|
3859
|
-
if (
|
|
5106
|
+
if (import_node_fs4.default.existsSync(file)) {
|
|
3860
5107
|
try {
|
|
3861
|
-
json3 = JSON.parse(
|
|
5108
|
+
json3 = JSON.parse(import_node_fs4.default.readFileSync(file, "utf8"));
|
|
3862
5109
|
} catch {
|
|
3863
|
-
|
|
5110
|
+
import_node_fs4.default.copyFileSync(file, file + ".bak");
|
|
3864
5111
|
done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
|
|
3865
5112
|
json3 = {};
|
|
3866
5113
|
}
|
|
3867
5114
|
} else {
|
|
3868
|
-
|
|
5115
|
+
import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(file), { recursive: true });
|
|
3869
5116
|
}
|
|
3870
5117
|
const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
|
|
3871
5118
|
const existed = "buoy" in servers;
|
|
3872
5119
|
servers.buoy = entry;
|
|
3873
5120
|
json3[key] = servers;
|
|
3874
|
-
|
|
5121
|
+
import_node_fs4.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
|
|
3875
5122
|
done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
|
|
3876
5123
|
}
|
|
3877
5124
|
var GUIDE_HEADING = "# Debugging with Buoy";
|
|
@@ -3880,6 +5127,13 @@ var DEBUGGING_GUIDE = `${GUIDE_HEADING}
|
|
|
3880
5127
|
This app's runtime is inspectable while it runs: the Buoy MCP tools read live
|
|
3881
5128
|
state straight from the device. Route by symptom before reading source:
|
|
3882
5129
|
|
|
5130
|
+
- \`get_triage\` \u2014 START HERE. One call: device, recent console errors and
|
|
5131
|
+
crashes, recent network requests, storage keys, and what's on screen. It
|
|
5132
|
+
usually names the problem, and always names the tool to drill into next.
|
|
5133
|
+
- \`run_flow\` \u2014 reproduce a bug or verify a fix in ONE call: a scripted
|
|
5134
|
+
tap/type/wait/assert sequence that returns the final screen plus the network
|
|
5135
|
+
and console output it produced. Use it instead of alternating
|
|
5136
|
+
\`describe_screen\` and \`tap_element\` a dozen times.
|
|
3883
5137
|
- \`get_events\` \u2014 the universal activity timeline (network, state changes,
|
|
3884
5138
|
storage writes, routes, renders). Check it BEFORE driving the UI to
|
|
3885
5139
|
reproduce a bug \u2014 the failure is usually already recorded.
|
|
@@ -3887,19 +5141,46 @@ state straight from the device. Route by symptom before reading source:
|
|
|
3887
5141
|
request + response bodies instead of guessing from code. Boot-time requests
|
|
3888
5142
|
are captured too.
|
|
3889
5143
|
- \`get_console\` \u2014 crashes and errors, including FATAL uncaught JS errors
|
|
3890
|
-
(tagged \`[FATAL]
|
|
5144
|
+
(tagged \`[FATAL]\`, with the component that crashed). Readable even after the
|
|
5145
|
+
app dies, so a redboxed app still tells you why.
|
|
3891
5146
|
- \`get_storage\` \u2014 data wrong or missing after an app restart \u2192 read the
|
|
3892
5147
|
persisted values FIRST.
|
|
5148
|
+
- \`reload_app\` \u2014 recover a crashed app (the other tools stop answering until
|
|
5149
|
+
you do) or pick up a change Fast Refresh missed.
|
|
3893
5150
|
- \`get_snapshot\` \u2014 full current state of any one tool when the compact views
|
|
3894
5151
|
aren't enough.
|
|
3895
5152
|
|
|
3896
|
-
Runtime facts beat guesses
|
|
5153
|
+
Runtime facts beat guesses \u2014 and after you edit, prove the fix on the device:
|
|
5154
|
+
reload, then re-drive EVERY screen the symptom could show up on and assert the
|
|
5155
|
+
corrected value. Fixes usually fail by being incomplete, not by being wrong.
|
|
5156
|
+
|
|
5157
|
+
## Don't report a fix you haven't watched work
|
|
5158
|
+
|
|
5159
|
+
Saying "fixed" is a claim about how the app behaves, so check it the same way
|
|
5160
|
+
you'd check anything else about the app \u2014 by looking at it. Re-run the exact
|
|
5161
|
+
interaction that was broken and report what the numbers did:
|
|
5162
|
+
|
|
5163
|
+
- \`measure_renders\` with the same \`steps\` and \`compareToPrevious: true\` for a
|
|
5164
|
+
performance change \u2014 it prints the before/after delta and flags a change that
|
|
5165
|
+
made things worse.
|
|
5166
|
+
- \`run_flow\` re-driving the broken screen for a behaviour change.
|
|
5167
|
+
|
|
5168
|
+
Two things this catches that reading cannot: a fix that is right but incomplete
|
|
5169
|
+
(the value was wrong in three places and you corrected one), and a fix that
|
|
5170
|
+
moved the cost instead of removing it \u2014 the usual failure mode for re-render
|
|
5171
|
+
work, where a page gets faster to type in and slower to tap.
|
|
5172
|
+
|
|
5173
|
+
If the app changed since anything was last checked, Buoy will say so in a tool
|
|
5174
|
+
result. Whether that's worth acting on is your call \u2014 a doc edit isn't worth a
|
|
5175
|
+
device check, a state-management change is. Set \`BUOY_VERIFY\` in the MCP config
|
|
5176
|
+
to \`always\` (repeat until checked) or \`never\` (silent) to change how often it
|
|
5177
|
+
mentions it.
|
|
3897
5178
|
`;
|
|
3898
5179
|
function appendDebuggingGuide(file, cwd, done) {
|
|
3899
5180
|
let existing = "";
|
|
3900
|
-
if (
|
|
5181
|
+
if (import_node_fs4.default.existsSync(file)) {
|
|
3901
5182
|
try {
|
|
3902
|
-
existing =
|
|
5183
|
+
existing = import_node_fs4.default.readFileSync(file, "utf8");
|
|
3903
5184
|
} catch {
|
|
3904
5185
|
done.push(`\u26A0 could not read ${rel(file, cwd)} \u2014 skipped the debugging guide`);
|
|
3905
5186
|
return;
|
|
@@ -3911,40 +5192,40 @@ function appendDebuggingGuide(file, cwd, done) {
|
|
|
3911
5192
|
}
|
|
3912
5193
|
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3913
5194
|
try {
|
|
3914
|
-
|
|
5195
|
+
import_node_fs4.default.writeFileSync(file, existing + separator + DEBUGGING_GUIDE);
|
|
3915
5196
|
done.push(`${existing ? "appended" : "created"} "${GUIDE_HEADING}" block in ${rel(file, cwd)}`);
|
|
3916
5197
|
} catch {
|
|
3917
5198
|
done.push(`\u26A0 could not write ${rel(file, cwd)} \u2014 skipped the debugging guide`);
|
|
3918
5199
|
}
|
|
3919
5200
|
}
|
|
3920
5201
|
function installSkills(skillSrcDir, cwd, done) {
|
|
3921
|
-
if (!
|
|
5202
|
+
if (!import_node_fs4.default.existsSync(skillSrcDir)) {
|
|
3922
5203
|
done.push("\u26A0 no bundled skills found \u2014 skipped");
|
|
3923
5204
|
return;
|
|
3924
5205
|
}
|
|
3925
|
-
const skills =
|
|
5206
|
+
const skills = import_node_fs4.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs4.default.existsSync(import_node_path4.default.join(skillSrcDir, e.name, "SKILL.md")));
|
|
3926
5207
|
if (skills.length === 0) {
|
|
3927
5208
|
done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
|
|
3928
5209
|
return;
|
|
3929
5210
|
}
|
|
3930
5211
|
for (const skill of skills) {
|
|
3931
|
-
const from =
|
|
3932
|
-
const to =
|
|
5212
|
+
const from = import_node_path4.default.join(skillSrcDir, skill.name);
|
|
5213
|
+
const to = import_node_path4.default.join(cwd, ".claude", "skills", skill.name);
|
|
3933
5214
|
copyDir(from, to);
|
|
3934
|
-
done.push(`installed the ${skill.name} skill \u2192 ${rel(
|
|
5215
|
+
done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path4.default.join(to, "SKILL.md"), cwd)}`);
|
|
3935
5216
|
}
|
|
3936
5217
|
}
|
|
3937
5218
|
function copyDir(from, to) {
|
|
3938
|
-
|
|
3939
|
-
for (const entry of
|
|
3940
|
-
const s =
|
|
3941
|
-
const d =
|
|
5219
|
+
import_node_fs4.default.mkdirSync(to, { recursive: true });
|
|
5220
|
+
for (const entry of import_node_fs4.default.readdirSync(from, { withFileTypes: true })) {
|
|
5221
|
+
const s = import_node_path4.default.join(from, entry.name);
|
|
5222
|
+
const d = import_node_path4.default.join(to, entry.name);
|
|
3942
5223
|
if (entry.isDirectory()) copyDir(s, d);
|
|
3943
|
-
else
|
|
5224
|
+
else import_node_fs4.default.copyFileSync(s, d);
|
|
3944
5225
|
}
|
|
3945
5226
|
}
|
|
3946
5227
|
function rel(file, cwd) {
|
|
3947
|
-
const r =
|
|
5228
|
+
const r = import_node_path4.default.relative(cwd, file);
|
|
3948
5229
|
return r && !r.startsWith("..") ? r : file;
|
|
3949
5230
|
}
|
|
3950
5231
|
// Annotate the CommonJS export names for ESM import in node:
|