@buoy-gg/mcp 7.0.26 → 7.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -46,6 +46,33 @@ var DEVICE_RETENTION_MS = 15 * 6e4;
46
46
  var DASHBOARD_NAME = "Dashboard";
47
47
  var ALL_DEVICES = "All";
48
48
 
49
+ // src/sync/actionTimeout.ts
50
+ function isRecoveryAction(toolId, action) {
51
+ return toolId === "app" && (action === "ping" || action === "reloadApp");
52
+ }
53
+ function isUnresponsive(consecutiveTimeouts) {
54
+ return consecutiveTimeouts >= 2;
55
+ }
56
+ function actionTimeoutMs(input) {
57
+ if (isRecoveryAction(input.toolId, input.action)) {
58
+ return { timeoutMs: input.fullMs, usedFullBudget: false };
59
+ }
60
+ if (!isUnresponsive(input.consecutiveTimeouts)) {
61
+ return { timeoutMs: input.fullMs, usedFullBudget: false };
62
+ }
63
+ if (input.now - input.lastFullBudgetAt > input.recoveryProbeMs) {
64
+ return { timeoutMs: input.fullMs, usedFullBudget: true };
65
+ }
66
+ return { timeoutMs: input.shortMs, usedFullBudget: false };
67
+ }
68
+ function diagnoseCrashed(opts) {
69
+ if (opts.consecutiveTimeouts >= 2) return true;
70
+ if (opts.lastOkAt > 0 && opts.now - opts.lastOkAt > opts.unresponsiveAfterMs) {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+
49
76
  // src/sync/SyncClient.ts
50
77
  var SyncClient = class {
51
78
  constructor(opts = {}) {
@@ -314,27 +341,34 @@ var SyncClient = class {
314
341
  noteTimeout(deviceId) {
315
342
  this.healthOf(deviceId).consecutiveTimeouts += 1;
316
343
  }
317
- /** True once a device has missed a call and hasn't answered since. */
344
+ /** True once a device has missed TWO calls and hasn't answered since. */
318
345
  isUnresponsive(deviceId) {
319
- return this.healthOf(deviceId).consecutiveTimeouts > 0;
346
+ return isUnresponsive(this.healthOf(deviceId).consecutiveTimeouts);
320
347
  }
321
348
  /**
322
349
  * Full timeout normally; a short one for a device already not answering.
323
350
  *
324
- * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
325
- * full budget again. Without it the flag is self-sustaining — a device whose
326
- * work legitimately takes longer than the short budget (a big snapshot, a
327
- * slow simulator) can never answer, so it can never clear the flag, and every
328
- * later reply is a confident "most likely CRASHED" about a healthy app.
351
+ * A single missed call does not trigger the short budget — one slow
352
+ * `describeScreen` is not a crash. `app.ping` / `app.reloadApp` always get
353
+ * the full budget (they are the recovery path).
354
+ *
355
+ * Escape hatch: every RECOVERY_PROBE_MS a flagged device gets the full
356
+ * budget again so the short timeout cannot become self-sustaining.
329
357
  */
330
- timeoutFor(deviceId) {
331
- if (!this.isUnresponsive(deviceId)) return this.actionTimeoutMs;
358
+ timeoutFor(deviceId, toolId, action) {
332
359
  const h = this.healthOf(deviceId);
333
- if (Date.now() - h.lastFullBudgetAt > RECOVERY_PROBE_MS) {
334
- h.lastFullBudgetAt = Date.now();
335
- return this.actionTimeoutMs;
336
- }
337
- return UNRESPONSIVE_ACTION_TIMEOUT_MS;
360
+ const { timeoutMs, usedFullBudget } = actionTimeoutMs({
361
+ consecutiveTimeouts: h.consecutiveTimeouts,
362
+ lastFullBudgetAt: h.lastFullBudgetAt,
363
+ now: Date.now(),
364
+ fullMs: this.actionTimeoutMs,
365
+ shortMs: UNRESPONSIVE_ACTION_TIMEOUT_MS,
366
+ recoveryProbeMs: RECOVERY_PROBE_MS,
367
+ toolId,
368
+ action
369
+ });
370
+ if (usedFullBudget) h.lastFullBudgetAt = Date.now();
371
+ return timeoutMs;
338
372
  }
339
373
  /**
340
374
  * Plain-language diagnosis for a device that isn't answering, including the
@@ -347,7 +381,12 @@ var SyncClient = class {
347
381
  unresponsiveNote(deviceId) {
348
382
  const h = this.healthOf(deviceId);
349
383
  const since = h.lastOkAt ? Math.round((Date.now() - h.lastOkAt) / 1e3) : null;
350
- const crashed = h.consecutiveTimeouts >= 2 || h.lastOkAt === 0 || Date.now() - h.lastOkAt > UNRESPONSIVE_AFTER_MS;
384
+ const crashed = diagnoseCrashed({
385
+ consecutiveTimeouts: h.consecutiveTimeouts,
386
+ lastOkAt: h.lastOkAt,
387
+ now: Date.now(),
388
+ unresponsiveAfterMs: UNRESPONSIVE_AFTER_MS
389
+ });
351
390
  const lines = [
352
391
  `The app on "${deviceId}" is not responding (${h.consecutiveTimeouts} call${h.consecutiveTimeouts === 1 ? "" : "s"} unanswered${since != null ? `, last answered ${since}s ago` : ""}).`,
353
392
  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."
@@ -549,7 +588,7 @@ var SyncClient = class {
549
588
  const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
550
589
  if (!this.socket) throw new Error("SyncClient is not connected");
551
590
  const requestId = (0, import_node_crypto.randomUUID)();
552
- const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target);
591
+ const timeoutMs = opts.timeoutMs ?? this.timeoutFor(target, toolId, action);
553
592
  const result = await new Promise((resolve, reject) => {
554
593
  const timer = setTimeout(() => {
555
594
  this.pendingActions.delete(requestId);
@@ -612,7 +651,7 @@ var import_node_os = __toESM(require("os"));
612
651
  var import_node_path = __toESM(require("path"));
613
652
 
614
653
  // src/version.ts
615
- var VERSION = "7.0.26" ? "7.0.26" : "0.0.0-dev";
654
+ var VERSION = "7.0.28" ? "7.0.28" : "0.0.0-dev";
616
655
  var PACKAGE_NAME = "@buoy-gg/mcp";
617
656
 
618
657
  // src/update/checkForUpdate.ts
@@ -1638,7 +1677,14 @@ function statusIcon(e) {
1638
1677
  return "\u2705";
1639
1678
  }
1640
1679
  function statusLabel(e) {
1641
- if (e.error) return `ERR ${e.error}`;
1680
+ if (e.status !== void 0 && e.status > 0) return String(e.status);
1681
+ if (e.error) {
1682
+ const cleaned = e.error.replace(/^HTTP\s+\d+:\s*/i, "").trim();
1683
+ if (!cleaned || cleaned === "undefined" || cleaned === "no error") {
1684
+ return "ERR";
1685
+ }
1686
+ return `ERR ${e.error}`;
1687
+ }
1642
1688
  if (e.status === void 0) return "pending";
1643
1689
  return String(e.status);
1644
1690
  }
@@ -2098,10 +2144,20 @@ A rule that never fires is usually one of: the master switch is off, the interce
2098
2144
 
2099
2145
  // src/tools/packs/reactQuery.ts
2100
2146
  var import_zod5 = require("zod");
2101
- var RQ_TOOL_ID = "react-query";
2147
+ var RQ_TOOL_ID = "query";
2148
+ var RQ_TOOL_ID_LEGACY = "react-query";
2102
2149
  var text4 = (s) => ({ content: [{ type: "text", text: s }] });
2103
2150
  var fail5 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
2104
2151
  var errMsg5 = (e) => e instanceof Error ? e.message : String(e);
2152
+ async function callQueryAction(sync, action, params, target) {
2153
+ try {
2154
+ return await sync.callAction(RQ_TOOL_ID, action, params, target);
2155
+ } catch (e) {
2156
+ const msg = errMsg5(e);
2157
+ if (!/unknown tool/i.test(msg)) throw e;
2158
+ return await sync.callAction(RQ_TOOL_ID_LEGACY, action, params, target);
2159
+ }
2160
+ }
2105
2161
  var STATUS_ICON = {
2106
2162
  fresh: "\u{1F7E2}",
2107
2163
  stale: "\u{1F7E1}",
@@ -2134,8 +2190,8 @@ function registerReactQueryTools(server, sync) {
2134
2190
  async (args) => {
2135
2191
  try {
2136
2192
  const target = sync.requireProDevice(args.deviceId);
2137
- const res = await sync.callAction(
2138
- RQ_TOOL_ID,
2193
+ const res = await callQueryAction(
2194
+ sync,
2139
2195
  "listQueries",
2140
2196
  { includeData: args.includeData ?? false, staleOnly: args.staleOnly ?? false, limit: args.limit ?? 25 },
2141
2197
  target
@@ -2181,13 +2237,13 @@ ${lines.join("\n")}${note}`);
2181
2237
  try {
2182
2238
  const target = sync.requireProDevice(args.deviceId);
2183
2239
  if (args.action === "clear") {
2184
- await sync.callAction(RQ_TOOL_ID, "clearQueryCache", {}, target);
2240
+ await callQueryAction(sync, "clearQueryCache", {}, target);
2185
2241
  return text4("Cleared the entire React Query cache.");
2186
2242
  }
2187
2243
  if (!args.queryHash) {
2188
2244
  return fail5(`'${args.action}' needs a queryHash. Call get_react_query first and use a row's hash (in backticks).`);
2189
2245
  }
2190
- await sync.callAction(RQ_TOOL_ID, args.action, { queryHash: args.queryHash }, target);
2246
+ await callQueryAction(sync, args.action, { queryHash: args.queryHash }, target);
2191
2247
  return text4(`Ran ${args.action} on query \`${args.queryHash}\`.`);
2192
2248
  } catch (e) {
2193
2249
  return fail5(errMsg5(e));
@@ -2614,17 +2670,23 @@ ${shown.map((r) => `- \`${r}\``).join("\n")}`
2614
2670
  server.registerTool(
2615
2671
  "navigate",
2616
2672
  {
2617
- description: "Navigate the app to a route path (e.g. '/pokemon/25' or '/settings'). Use a concrete path \u2014 resolve any dynamic segments (like [id]) to real values first. See get_routes with includeSitemap:true for valid paths.",
2673
+ description: "Navigate the app to a route path (e.g. '/pokemon/25' or '/settings'). Use a concrete path \u2014 resolve any dynamic segments (like [id]) to real values first. See get_routes with includeSitemap:true for valid paths. Pushes by default (back still works); pass replace:true to leave the current screen instead of stacking on top of it (e.g. resetting to '/').",
2618
2674
  inputSchema: {
2619
2675
  path: import_zod10.z.string().describe("The concrete path to navigate to, e.g. '/pokemon/25'."),
2676
+ replace: import_zod10.z.boolean().optional().describe("Replace the current screen instead of pushing (use when resetting, e.g. to '/')."),
2620
2677
  deviceId: import_zod10.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2621
2678
  }
2622
2679
  },
2623
2680
  async (args) => {
2624
2681
  try {
2625
2682
  const target = sync.requireProDevice(args.deviceId);
2626
- await sync.callAction(ROUTES_TOOL_ID, "navigate", { path: args.path }, target);
2627
- return text9(`Navigated to \`${args.path}\`.`);
2683
+ await sync.callAction(
2684
+ ROUTES_TOOL_ID,
2685
+ "navigate",
2686
+ { path: args.path, replace: args.replace === true },
2687
+ target
2688
+ );
2689
+ return text9(`Navigated to \`${args.path}\`${args.replace ? " (replaced current screen)" : ""}.`);
2628
2690
  } catch (e) {
2629
2691
  return fail10(errMsg10(e));
2630
2692
  }
@@ -2924,6 +2986,40 @@ async function cropToBuffer(srcPath, full, region) {
2924
2986
  }
2925
2987
  }
2926
2988
 
2989
+ // src/tools/packs/screenshotLocate.ts
2990
+ function pickDescribeScreenMatch(elements, query, nativeTag) {
2991
+ if (nativeTag != null) {
2992
+ const exact = elements.find((e) => e.nativeTag === nativeTag);
2993
+ if (exact) return exact;
2994
+ }
2995
+ const q = (query ?? "").trim().toLowerCase();
2996
+ if (!q) return void 0;
2997
+ let best;
2998
+ for (const el of elements) {
2999
+ const fields = [el.testID, el.label, el.text, el.name].filter(Boolean).map((s) => s.toLowerCase());
3000
+ let score = 0;
3001
+ for (const f of fields) {
3002
+ if (f === q) score = Math.max(score, 100);
3003
+ else if (f.startsWith(q)) score = Math.max(score, 50);
3004
+ else if (f.includes(q)) score = Math.max(score, 25);
3005
+ }
3006
+ const tap = el.tap;
3007
+ if (tap && tap.x >= 0 && tap.x <= 1 && tap.y >= 0 && tap.y <= 1) score += 20;
3008
+ else if (tap) score -= 40;
3009
+ if (score <= 0) continue;
3010
+ if (!best || score > best.score) best = { el, score };
3011
+ }
3012
+ return best?.el;
3013
+ }
3014
+ function frameToRect(frame, screen) {
3015
+ return {
3016
+ x: frame.x * screen.width,
3017
+ y: frame.y * screen.height,
3018
+ width: frame.width * screen.width,
3019
+ height: frame.height * screen.height
3020
+ };
3021
+ }
3022
+
2927
3023
  // src/tools/packs/screenshot.ts
2928
3024
  var TOOL_ID = "highlight-updates";
2929
3025
  var MARGIN_PT = 12;
@@ -2933,7 +3029,7 @@ var errMsg13 = (e) => e instanceof Error ? e.message : String(e);
2933
3029
  function reasonMessage(res, query) {
2934
3030
  switch (res.reason) {
2935
3031
  case "no-renders":
2936
- return "No components are tracked yet. Navigate to the screen with the target component in the running app (it needs to render at least once), then retry.";
3032
+ return "No components matched via highlight tracking or the live fiber walk. Pass a testID / name from describe_screen, then retry.";
2937
3033
  case "no-match":
2938
3034
  return query ? `Nothing matched "${query}".` : "No query or nativeTag given \u2014 pass one, or a testID/name to search for.";
2939
3035
  case "no-measurement":
@@ -2957,7 +3053,7 @@ function registerScreenshotTools(server, sync) {
2957
3053
  server.registerTool(
2958
3054
  "screenshot_component",
2959
3055
  {
2960
- description: "Take a tight, cropped screenshot of a single React Native component running in the iOS Simulator \u2014 no manual coordinates needed. Give a testID, nativeID, accessibilityLabel, or component name in `query` (or an exact `nativeTag` from a previous call's candidates) and it locates the live component on-device (scrolling it into view), then captures + crops the Simulator framebuffer to just that component. The component must have rendered at least once since the app opened for it to be trackable \u2014 if nothing matches, navigate to the screen that renders it and retry. iOS Simulator only, and the MCP server must be running on the same Mac as the booted Simulator (uses `xcrun simctl` + `sips`). Requires Buoy Pro.",
3056
+ description: "Take a tight, cropped screenshot of a single React Native component running in the iOS Simulator \u2014 no manual coordinates needed. Give a testID, nativeID, accessibilityLabel, or component name in `query` (or an exact `nativeTag` from a previous call's candidates) and it locates the live component on-device (scrolling it into view), then captures + crops the Simulator framebuffer to just that component. If highlight tracking hasn't seen a render yet, it falls back to a live fiber walk (same as describe_screen). iOS Simulator only, and the MCP server must run on the same Mac as the booted Simulator. Requires Buoy Pro.",
2961
3057
  inputSchema: {
2962
3058
  query: import_zod13.z.string().optional().describe('Fuzzy match against testID, nativeID, accessibilityLabel, component name, or view type \u2014 e.g. "submit-button".'),
2963
3059
  nativeTag: import_zod13.z.number().optional().describe("An exact nativeTag (from a previous call's candidates) to skip fuzzy matching."),
@@ -2981,15 +3077,37 @@ function registerScreenshotTools(server, sync) {
2981
3077
  },
2982
3078
  target
2983
3079
  );
2984
- if (!res.matched || !res.rect || !res.screen) {
2985
- return fail13(reasonMessage(res, args.query) + candidatesText(res.candidates));
3080
+ let located = res;
3081
+ if (!located.matched && (located.reason === "no-renders" || located.reason === "no-match")) {
3082
+ const screen = await sync.callAction(TOOL_ID, "describeScreen", {}, target).catch(() => null);
3083
+ const match = screen?.elements ? pickDescribeScreenMatch(screen.elements, args.query, args.nativeTag) : void 0;
3084
+ if (match?.frame && screen?.screen) {
3085
+ located = {
3086
+ matched: true,
3087
+ live: true,
3088
+ rect: frameToRect(match.frame, screen.screen),
3089
+ screen: screen.screen,
3090
+ nativeTag: match.nativeTag ?? void 0,
3091
+ componentName: match.name,
3092
+ testID: match.testID
3093
+ };
3094
+ } else if (located.reason === "no-renders") {
3095
+ located = {
3096
+ matched: false,
3097
+ reason: "no-match",
3098
+ candidates: located.candidates
3099
+ };
3100
+ }
3101
+ }
3102
+ if (!located.matched || !located.rect || !located.screen) {
3103
+ return fail13(reasonMessage(located, args.query) + candidatesText(located.candidates));
2986
3104
  }
2987
3105
  const shot = await captureIosFramebuffer();
2988
3106
  if (!shot.ok || !shot.srcPath || !shot.width || !shot.height) {
2989
3107
  return fail13(shot.error ?? "Screenshot capture failed.");
2990
3108
  }
2991
- const pxScale = shot.width / res.screen.width;
2992
- const r = res.rect;
3109
+ const pxScale = shot.width / located.screen.width;
3110
+ const r = located.rect;
2993
3111
  const pad = CAPTURE_PADDING_PT;
2994
3112
  const crop = await cropToBuffer(
2995
3113
  shot.srcPath,
@@ -3004,9 +3122,9 @@ function registerScreenshotTools(server, sync) {
3004
3122
  if (!crop.ok || !crop.buffer) {
3005
3123
  return fail13(crop.error ?? "Crop failed.");
3006
3124
  }
3007
- const label = res.componentName || "component";
3008
- const idPart = res.testID ? ` (testID: "${res.testID}")` : "";
3009
- const warn = res.inView === false ? "\n\nNote: the component is partly off-screen even after scrolling, so the crop is clamped to the screen edge." : "";
3125
+ const label = located.componentName || "component";
3126
+ const idPart = located.testID ? ` (testID: "${located.testID}")` : "";
3127
+ const warn = located.inView === false ? "\n\nNote: the component is partly off-screen even after scrolling, so the crop is clamped to the screen edge." : "";
3010
3128
  const summary = `Captured "${label}"${idPart} \u2014 ${crop.width}\xD7${crop.height}px.${warn}`;
3011
3129
  return {
3012
3130
  content: [
@@ -3570,6 +3688,19 @@ ${JSON.stringify(slim)}
3570
3688
 
3571
3689
  // src/tools/packs/assets.ts
3572
3690
  var import_zod17 = require("zod");
3691
+
3692
+ // src/tools/packs/assetsGraphNote.ts
3693
+ function graphUnavailableNote(ss) {
3694
+ if (ss.graphSupported && ss.graphCount != null) return null;
3695
+ const err = (ss.graphError ?? "").trim();
3696
+ const looksLikeDev = ss.dev === true || !!err;
3697
+ if (looksLikeDev) {
3698
+ return err ? `\u26A0 Metro bundle graph unavailable (${err}) \u2014 showing runtime-registered assets only; never-loaded detection unavailable.` : "\u26A0 Metro bundle graph unavailable \u2014 showing runtime-registered assets only; never-loaded detection unavailable.";
3699
+ }
3700
+ return "\u26A0 No Metro bundle graph (release build, or graph fetch failed) \u2014 showing runtime-registered assets only; never-loaded detection unavailable.";
3701
+ }
3702
+
3703
+ // src/tools/packs/assets.ts
3573
3704
  var ASSETS_TOOL_ID = "assets";
3574
3705
  var text15 = (s) => ({ content: [{ type: "text", text: s }] });
3575
3706
  var fail17 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
@@ -3612,11 +3743,8 @@ function formatListMarkdown2(list, limit) {
3612
3743
  if (ss.fontFamilies.length > 0) {
3613
3744
  lines.push(`Loaded font families: ${ss.fontFamilies.join(", ")}`);
3614
3745
  }
3615
- if (!ss.graphSupported || ss.graphCount == null) {
3616
- lines.push(
3617
- "\u26A0 No Metro bundle graph (release build or graph fetch failed) \u2014 showing runtime-registered assets only; never-loaded detection unavailable." + (ss.graphError ? ` (${ss.graphError})` : "")
3618
- );
3619
- }
3746
+ const graphNote = graphUnavailableNote(ss);
3747
+ if (graphNote) lines.push(graphNote);
3620
3748
  if (ss.measuring) lines.push("\u26A0 Size measurement in progress \u2014 byte totals are partial; re-run get_assets shortly.");
3621
3749
  const ins = list.insights;
3622
3750
  const chips = [];
@@ -3905,16 +4033,14 @@ function registerReloadTools(server, sync) {
3905
4033
  const previousRealm = args.wait === false ? null : await ping(sync, target, PROBE_TIMEOUT_MS);
3906
4034
  let ack = null;
3907
4035
  let recovered = false;
3908
- try {
3909
- ack = await sync.callAction(
3910
- APP_TOOL_ID,
3911
- RELOAD_ACTION,
3912
- { strategy: args.strategy ?? "auto" },
3913
- target
3914
- );
3915
- } catch (inAppError) {
4036
+ const tryDevServer = async (inAppError) => {
3916
4037
  const origin = sync.devServerOrigin(target);
3917
- if (!origin) throw inAppError;
4038
+ if (!origin) {
4039
+ if (inAppError) throw inAppError;
4040
+ throw new Error(
4041
+ "The app is not answering and no dev-server origin is known (it was never learned from a successful ping). Reload the app by hand."
4042
+ );
4043
+ }
3918
4044
  const res = await reloadViaDevServer(origin);
3919
4045
  if (!res.ok) {
3920
4046
  throw new Error(
@@ -3922,6 +4048,20 @@ function registerReloadTools(server, sync) {
3922
4048
  );
3923
4049
  }
3924
4050
  recovered = true;
4051
+ };
4052
+ if (sync.isUnresponsive(target) && sync.devServerOrigin(target)) {
4053
+ await tryDevServer();
4054
+ } else {
4055
+ try {
4056
+ ack = await sync.callAction(
4057
+ APP_TOOL_ID,
4058
+ RELOAD_ACTION,
4059
+ { strategy: args.strategy ?? "auto" },
4060
+ target
4061
+ );
4062
+ } catch (inAppError) {
4063
+ await tryDevServer(inAppError);
4064
+ }
3925
4065
  }
3926
4066
  const via = recovered ? ` via the dev server (the app was unresponsive \u2014 its own reload could not be reached)` : ack?.strategy ? ` via ${ack.strategy}` : "";
3927
4067
  if (args.wait === false) {
@@ -4931,9 +5071,8 @@ function runInit(opts = {}) {
4931
5071
  const entry = resolveEntry(opts, log);
4932
5072
  const done = [];
4933
5073
  upsertMcpConfig(import_node_path4.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
4934
- if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".cursor"))) {
4935
- upsertMcpConfig(import_node_path4.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
4936
- }
5074
+ import_node_fs4.default.mkdirSync(import_node_path4.default.join(cwd, ".cursor"), { recursive: true });
5075
+ upsertMcpConfig(import_node_path4.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
4937
5076
  if (import_node_fs4.default.existsSync(import_node_path4.default.join(cwd, ".vscode"))) {
4938
5077
  upsertMcpConfig(import_node_path4.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
4939
5078
  }
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-NPMUQLCZ.mjs";
6
+ } from "./chunk-SFG6ZYH6.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.d.mts CHANGED
@@ -127,16 +127,17 @@ declare class SyncClient {
127
127
  * first miss we stop paying the full timeout on every subsequent call.
128
128
  */
129
129
  private noteTimeout;
130
- /** True once a device has missed a call and hasn't answered since. */
130
+ /** True once a device has missed TWO calls and hasn't answered since. */
131
131
  isUnresponsive(deviceId: string): boolean;
132
132
  /**
133
133
  * Full timeout normally; a short one for a device already not answering.
134
134
  *
135
- * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
136
- * full budget again. Without it the flag is self-sustaining — a device whose
137
- * work legitimately takes longer than the short budget (a big snapshot, a
138
- * slow simulator) can never answer, so it can never clear the flag, and every
139
- * later reply is a confident "most likely CRASHED" about a healthy app.
135
+ * A single missed call does not trigger the short budget — one slow
136
+ * `describeScreen` is not a crash. `app.ping` / `app.reloadApp` always get
137
+ * the full budget (they are the recovery path).
138
+ *
139
+ * Escape hatch: every RECOVERY_PROBE_MS a flagged device gets the full
140
+ * budget again so the short timeout cannot become self-sustaining.
140
141
  */
141
142
  private timeoutFor;
142
143
  /**
@@ -244,8 +245,9 @@ declare function createMcpServer(sync: SyncClient): McpServer;
244
245
  * project, so a user doesn't have to hand-edit any config.
245
246
  *
246
247
  * - Registers the `buoy` server in the editor MCP config(s): `.mcp.json`
247
- * (Claude Code, always), and `.cursor/mcp.json` / `.vscode/mcp.json` when
248
- * those editor folders already exist.
248
+ * (Claude Code, always), `.cursor/mcp.json` (always — Cursor is a first-class
249
+ * client; we create `.cursor/` if needed), and `.vscode/mcp.json` when
250
+ * `.vscode/` already exists.
249
251
  * - Copies the `buoy-optimize` skill into `.claude/skills/`.
250
252
  *
251
253
  * Idempotent + non-destructive: merges into existing configs (never clobbers
package/dist/index.d.ts CHANGED
@@ -127,16 +127,17 @@ declare class SyncClient {
127
127
  * first miss we stop paying the full timeout on every subsequent call.
128
128
  */
129
129
  private noteTimeout;
130
- /** True once a device has missed a call and hasn't answered since. */
130
+ /** True once a device has missed TWO calls and hasn't answered since. */
131
131
  isUnresponsive(deviceId: string): boolean;
132
132
  /**
133
133
  * Full timeout normally; a short one for a device already not answering.
134
134
  *
135
- * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
136
- * full budget again. Without it the flag is self-sustaining — a device whose
137
- * work legitimately takes longer than the short budget (a big snapshot, a
138
- * slow simulator) can never answer, so it can never clear the flag, and every
139
- * later reply is a confident "most likely CRASHED" about a healthy app.
135
+ * A single missed call does not trigger the short budget — one slow
136
+ * `describeScreen` is not a crash. `app.ping` / `app.reloadApp` always get
137
+ * the full budget (they are the recovery path).
138
+ *
139
+ * Escape hatch: every RECOVERY_PROBE_MS a flagged device gets the full
140
+ * budget again so the short timeout cannot become self-sustaining.
140
141
  */
141
142
  private timeoutFor;
142
143
  /**
@@ -244,8 +245,9 @@ declare function createMcpServer(sync: SyncClient): McpServer;
244
245
  * project, so a user doesn't have to hand-edit any config.
245
246
  *
246
247
  * - Registers the `buoy` server in the editor MCP config(s): `.mcp.json`
247
- * (Claude Code, always), and `.cursor/mcp.json` / `.vscode/mcp.json` when
248
- * those editor folders already exist.
248
+ * (Claude Code, always), `.cursor/mcp.json` (always — Cursor is a first-class
249
+ * client; we create `.cursor/` if needed), and `.vscode/mcp.json` when
250
+ * `.vscode/` already exists.
249
251
  * - Copies the `buoy-optimize` skill into `.claude/skills/`.
250
252
  *
251
253
  * Idempotent + non-destructive: merges into existing configs (never clobbers